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.

ContainerWhat it isState
postgresthe main database: workflows, runs, chat history, credit accountingpostgres_data volume
redisrun queue, cross-process event bus (SSE), runtime stateredis_data volume
qdrantvector storage for knowledge basesqdrant_data volume
apiHTTP API and SSE; also the one applying migrations at startapp_data volume (files)
workerexecutes workflows off the queue — same image, different commandapp_data volume (the same one)
js-executorisolated sandbox for code nodesnone
frontendthe built SPA — the editor itselfnone
webthe site: documentation, examples, the public-chat pagenone
edgenginx: the single public entry, routing by domainnone

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.

VariableWhat it isIf left unset
SECRET_KEYmaster secret: JWT signing and file-link signatures are derived from itstartup aborts
CONNECTIONS_FERNET_KEYencryption key for connection secrets in the databasestartup aborts
CORS_ORIGINSwhich origins the browser may call the API from — strictly a JSON array'*' is rejected in production; non-JSON kills startup
POSTGRES_PASSWORD + DATABASE_URLthe database password; the connection string must agree with itthe app cannot connect
PUBLIC_BASE_URLthe external address of the app — webhooks are registered against itpublishing to Telegram is impossible
OPENROUTER_API_KEYthe platform's model-provider keythe 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:

  1. Bring the stack up and register normally through the interface.
  2. Put that email into BOOTSTRAP_ADMIN_EMAILS in .env (a JSON array).
  3. 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

QuestionYour own installation
Whose provider keysyours, in the instance .env; they need never leave the building
Which models are availablewhichever your YAML lists — local ones included
Who sets the limitsthe instance administrator in the console; enforcement can also be off
Where the data sitsyour volumes: postgres, qdrant, application files
Code nodesenabled explicitly and only together with the sandbox container
Updates and backupsyour 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.