Self-hosting
Flow ships as containers and comes up with docker compose: the repository carries a ready
file, infra/prod/docker-compose.yml, and an environment example next to it
(infra/prod/.env.example). It is a single machine running everything: the application, the
database, the queue, vector search, an isolated sandbox for user code, and an entry proxy.
The plan limits every other documentation page talks about are yours on your own
installation. Two plans — free and paid — are seeded into the database on first start,
but their numbers are declared in the code as placeholders, and the instance administrator
edits them right in the admin console. So “Telegram is not available on
free” is a decision made by whoever runs the installation, not a property of the platform.
What the stack is made of
Nine containers. Their roles are not interchangeable: three hold state, two execute workflows, two serve the interface, one isolates foreign code, one accepts traffic.
| Container | What it is | State |
|---|---|---|
| postgres | the main database: workflows, runs, chat history, credit accounting | postgres_data volume |
| redis | run queue, cross-process event bus (SSE), runtime state | redis_data volume |
| qdrant | vector storage for knowledge bases | qdrant_data volume |
| api | HTTP API and SSE; also the one applying migrations at start | app_data volume (files) |
| worker | executes workflows off the queue — same image, different command | app_data volume (the same one) |
| js-executor | isolated sandbox for code nodes | none |
| frontend | the built SPA — the editor itself | none |
| web | the site: documentation, examples, the public-chat page | none |
| edge | nginx: the single public entry, routing by domain | none |
Three things in that layout are not obvious, and all three break silently.
api and worker are the same image. They differ by the start command and by the fact
that only api applies migrations (RUN_MIGRATIONS=1): there must never be two migrating
processes. The engine lives in both, though — some runs (a synchronous call, a chat turn, an
agent used as a tool, a sub-workflow) execute inside api directly.
The file volume must be mounted on both. app_data holds message attachments,
node-generated files and knowledge-base source documents. The engine writes them from
worker and api serves them — with separate filesystems a download returns 404, and with
no volume at all every user file is destroyed by the first up --force-recreate.
The code sandbox has its own network and not a single secret. js-executor sits on a
network with no database, no Redis and no Qdrant, runs with a read-only root, no
capabilities, and caps on memory and process count, and it is deliberately given no
env_file. The point is that a sandbox escape lands in a container with nothing to steal.
More about the nodes themselves in Your own code.
What must be configured before the first start
The order is: copy infra/prod/.env.example to infra/prod/.env and fill it in. With
ENVIRONMENT=production the application validates its configuration at startup and
refuses to boot if a dangerous value was left at its default — deliberately, so an
installation never goes live on a key taken from an example file.
| Variable | What it is | If left unset |
|---|---|---|
| SECRET_KEY | master secret: JWT signing and file-link signatures are derived from it | startup aborts |
| CONNECTIONS_FERNET_KEY | encryption key for connection secrets in the database | startup aborts |
| CORS_ORIGINS | which origins the browser may call the API from — strictly a JSON array | '*' is rejected in production; non-JSON kills startup |
| POSTGRES_PASSWORD + DATABASE_URL | the database password; the connection string must agree with it | the app cannot connect |
| PUBLIC_BASE_URL | the external address of the app — webhooks are registered against it | publishing to Telegram is impossible |
| OPENROUTER_API_KEY | the platform's model-provider key | the catalog is visible, but every model call fails |
Two more addresses are set at image build time, not at runtime: PUBLIC_SITE_URL (where
the app points its documentation links) and PUBLIC_APP_URL. Vite and Astro inline them
into the bundle while building, so changing them needs a rebuild, not a restart. If
PUBLIC_SITE_URL is unset, the app simply renders no documentation links at all — the right
behaviour for an installation that does not publish the site.
The entry proxy does not terminate TLS: as shipped, edge listens on plain HTTP and
routes by domain (/api/… → api, the root of the main domain → the site, the app domain →
the SPA). Certificates belong on an upstream load balancer or in an extension of that
config; that is the instance administrator’s job.
The first administrator
The global admin role is what opens the Admin menu entry
(the console) and lifts limits off an account. The first such user is
provisioned through BOOTSTRAP_ADMIN_EMAILS, and the order matters here:
- Bring the stack up and register normally through the interface.
- Put that email into
BOOTSTRAP_ADMIN_EMAILSin.env(a JSON array). - Restart
api: the list is applied at startup and promotes users that already exist. An email with no account does not create one — it simply has no effect.
The scripts/grant_admin.py script run inside the api container gives the same result.
The role is read from the database on every request, so a promotion or a demotion takes
effect immediately, with no re-login. Roles are covered in
Team access.
Model catalogs are files, not code
The list of models the nodes see comes not from code but from YAML catalogs under
backend/config/: llm/, embeddings/, rerank/, media/. One file, one provider: its
name, the adapter class, the base address, the key (through an environment-variable
substitution) and the list of models with their prices in credits. Adding a model is a
file edit and a restart, not a code change. How the fields are laid out is in
Model catalog.
That is also the headline capability of running your own installation: point params.base_url
at your own OpenAI-compatible endpoint (LM Studio, vLLM, TEI) and get models that never leave
the building. Such a catalog already ships as an example — config/llm/local.yaml, with the
LOCAL_BASE_URL / LOCAL_API_KEY variables.
Plans, limits and money on your own installation
Plans live as database rows, not constants: the administrator edits the numbers on the Plans tab of the admin console and overrides limits for an individual user. A key absent from a plan means “default behaviour” — the value from the application configuration. What exactly gets limited is listed in Plan limits, and credits and how they are spent in Credits.
Credit accounting works on your own installation too: every run and every model call writes
a row into the usage ledger. That is useful even when nobody collects money — it shows what
a particular workflow costs. Plan enforcement can be switched off entirely with
PLAN_ENFORCEMENT_ENABLED=false; quotas, concurrency ceilings and credit gates then stop
applying to everyone.
Self-service plan purchase is off in the shipped configuration
(BILLING_SELF_SERVICE_ENABLED=false), and that is not an oversight: the default provider is
a stub whose confirm endpoint grants credits and a paid plan with no real charge. The startup
check refuses to bring production up with self-service billing enabled on the stub. On your
own installation the administrator hands out plans, not a checkout.
How your own installation differs from the hosted one
| Question | Your own installation |
|---|---|
| Whose provider keys | yours, in the instance .env; they need never leave the building |
| Which models are available | whichever your YAML lists — local ones included |
| Who sets the limits | the instance administrator in the console; enforcement can also be off |
| Where the data sits | your volumes: postgres, qdrant, application files |
| Code nodes | enabled explicitly and only together with the sandbox container |
| Updates and backups | your responsibility — no backup daemon ships in the stack |
What the platform does not do for the administrator: it does not issue certificates, does not take backups, does not stand up monitoring (trace and metric export over OTLP exists, but no collector is part of this compose file — its address is configured separately), and does not update itself. Run-log retention, on the other hand, is automatic: the worker prunes it using each owner’s plan retention window, with no separate service for the job.
What it asks for in resources
The memory limits of all nine containers in the shipped file add up to 8320 MiB
(reproduce it by summing the memory: values in infra/prod/docker-compose.yml). Those are
limits rather than consumption, but there is no point planning a machine below 8 GB; the
capacity plan (infra/CAPACITY_PLAN.md) recommends 8 vCPU / 16 GB on a single machine to
start.
Beyond that it comes down to two things: the number of concurrent runs
(MAX_PARALLEL_RUNS per worker process) and the Postgres connection pool — they are linked,
and raising the first without the second is pointless. The variables involved and the rule
tying them together are documented in the comments of backend/.env.example.