concile
Deploy & Operate

Self-hosting with Docker

concile serve and docker compose up, the baseline production self-host path.

Think of docker compose up as the single magic command that brings your entire backend to life. It spins up the sync engine, the HTTP API, any httpAction webhooks, and the dashboard, all bundled into one container.

This is the standard way to self-host concile. We use a generic concile:latest image that runs concile serve. Your app's concile/ folder gets mounted right in, and the SQLite database rests on a named volume to ensure your data sticks around between restarts.

On this page, we'll dive into serve itself, looking at how it differs from dev and reviewing all the flags and environment variables it uses. We'll also explore the included Docker setup, explain a couple of tricky fixes under the hood, check out the immutable-image option, and talk about what we intentionally leave for a reverse proxy to handle.

concile serve: the production entrypoint

Think of concile serve as the grown-up, production-ready sibling to concile dev. It skips the file watcher and codegen-write features to give you some serious, solid production hardening instead.

Under the hood, they both run on the exact same boot core: bootProject() (found in packages/cli/src/boot.ts). This core handles loading the project, composing components, opening the store, and building out the EmbeddedRuntime and AdminApi. Whether you're in dev or serve mode, things like routes, drivers, boot steps, context providers, and table numbers are completely identical.

When it comes to how your functions run, like schedulers, workflows, actions, httpActions, and triggers, there are no special features limited to just dev or serve. It's only the process-level behaviors wrapped around that shared core that actually change:

concile devconcile serve
CodegenRuns on every change, writes _generated/Never runs, fails fast if _generated/ is missing
Bind addressLoopback (127.0.0.1)0.0.0.0 by default
Admin keyEphemeral, auto-generated, embedded in the dashboard HTMLRequired (CONCILE_ADMIN_KEY), never embedded
File watchingWatches concile/ and hot-reloadsNone, restart (or concile deploy) to pick up changes
ShutdownCtrl-C exits immediatelySIGTERM/SIGINT trigger a graceful drain

Fail-fast checks, before anything binds

Before it even tries to open the store or bind a port, serveCommand checks a couple of quick things. If either check fails, it exits with an error code of 1 and gives you a clear, actionable message:

  1. You must provide a CONCILE_ADMIN_KEY, and it can't be blank. There aren't any defaults or fallbacks here, and if you just pass in spaces, it treats it as completely empty.

    ✗ CONCILE_ADMIN_KEY is required for `serve`: set it to a strong secret.
  2. The <dir>/_generated/server.ts file needs to exist already. Unlike dev mode, serve won't run codegen for you. That step is best left to your build or CI pipeline instead of running every time you boot up in production. Make sure to generate and commit this file before you deploy:

    concile codegen --dir concile
    ✗ concile/_generated not found: run `concile codegen --dir concile` and commit _generated/ before deploying.

Binding and graceful shutdown

By default, serve binds to 0.0.0.0, which is a bit different from dev's loopback-only setup. We designed it this way so it can be reached from outside the container or host. To actually expose it to the public, you'll typically use a reverse proxy or Docker port mapping.

When it's time to stop, both SIGTERM and SIGINT kick off the exact same safe shutdown sequence. It's perfectly fine if it receives the signal twice. If a second signal comes in while it's already shutting down, it just ignores it quietly.

  1. It stops the fleet node if you happen to be running one with --fleet.
  2. It calls server.close(), which halts all registered drivers like the scheduler, triggers, reapers, and wake heartbeats, and securely closes any listening sockets.
  3. It tries its best to release any object-storage lease if you're using --object-store. It gives this about 2 seconds, just to ensure an unreachable bucket doesn't cause a hang during the container's grace period.
  4. It calls store.close() to properly shut down the database connection or file handle.
  5. process.exit(0).

This careful sequence is exactly why commands like docker compose down or docker stop result in a clean, graceful stop instead of an abruptly killed process. Those commands send a SIGTERM followed by a SIGKILL after a short grace period. Thanks to our shutdown steps, the store closes safely before exiting, ensuring your database is never left in a broken or torn state.

What it serves

A single process handles everything on one origin. This includes the sync WebSocket, the /api/run and /api/action HTTP endpoints, your httpAction routes from http.ts, and the always-available /api/storage/* file-serving routes. It also takes care of any custom routes contributed by components, like OAuth callbacks from @concile/auth, and it even serves the dashboard SPA at /_dashboard unless you've turned it off.

The dashboard is served key-less

In dev mode, the ephemeral admin key is embedded right into the dashboard's HTML. That's perfectly safe since it only ever lives on your local loopback. But serve does things differently by calling loadDashboard(undefined). This means the admin key is never baked into the HTML that gets sent to the browser.

Instead, when the dashboard SPA loads up, it will ask you for the key. Just paste in whatever secret you assigned to CONCILE_ADMIN_KEY, and it will handle authenticating your /_admin calls from then on.

This is a really important security measure. Because serve is bound to 0.0.0.0, anyone who can reach that port could potentially access it. If we embedded a persistent secret directly into the HTML, it would be exposed to every single visitor, not just you as the operator.

Flags and environment variables

You can configure every serve option using either a CLI flag or an environment variable. If you happen to provide both, the flag will always take priority.

Keep in mind that serve only reads environment variables once during startup. We don't support live reconfigurations right now, so if you need to change any of these settings, you'll just need to give the process a quick restart.

FlagEnv varDefaultWhat it does
--dir <path>concileThe app directory to load (schema, functions, concile.config.ts, _generated/).
--data <path>CONCILE_DATA_DIR (sets <dir>/db.sqlite)./data/db.sqliteSQLite database file path. Ignored when --database-url selects Postgres.
--ip <address>0.0.0.0Bind address.
--port <n>PORT3000Bind port.
--no-dashboardCONCILE_DASHBOARD=offdashboard onDisable the dashboard SPA entirely (not just hide it, the route isn't served).
--allow-deployCONCILE_ALLOW_DEPLOY=1offEnable POST /_admin/deploy, the target of concile deploy's live hot-swap. See Deploy and build.
--database-url <url>CONCILE_DATABASE_URLunset (SQLite)Point at a Postgres database instead of the SQLite file. See Postgres.
--storage-bucket <name>CONCILE_STORAGE_BUCKETunset (local FS)Selects the S3-compatible file-storage backend; presence of a bucket is what switches ctx.storage from local disk to S3/MinIO/R2.
--storage-endpoint <url>CONCILE_STORAGE_ENDPOINTAWS defaultCustom S3 endpoint (MinIO, R2, etc.), only meaningful alongside a bucket.
--web <dir>CONCILE_WEB_DIRunsetServe a static frontend (index.html + assets) at the site root, same origin as the sync WebSocket.

You'll notice that CONCILE_ADMIN_KEY is required but doesn't have a matching CLI flag. We did this on purpose! Secrets should live safely in your environment variables, not in a process argument list where anyone running ps could easily spot them.

Docker: the baseline self-host path

Before you start

Make sure you have a concile/ directory with your committed _generated/ folder. As we mentioned earlier, serve will quickly fail if it can't find it. Just generate it before you build your image:

concile codegen --dir concile

You'll also need to have Docker and Docker Compose installed and ready to go.

Set a strong admin key

First things first, drop your key into a .env file right next to your docker-compose.yml. Docker Compose is smart enough to load .env files automatically for you:

.env
CONCILE_ADMIN_KEY=$(openssl rand -hex 32)

The docker-compose.yml file we provide actually enforces this at the environment-variable level using CONCILE_ADMIN_KEY: ${CONCILE_ADMIN_KEY:?set CONCILE_ADMIN_KEY in a .env file}. Because of this, Compose will kindly refuse to even start the container if the variable is missing, stopping things before serve even gets a chance to run its own checks.

Run docker compose up

docker compose up

Here's a look at the docker-compose.yml file we include:

docker-compose.yml
services:
  concile:
    build:
      context: .
      target: runner
    image: concile:latest
    ports:
      - "3000:3000"
    environment:
      CONCILE_ADMIN_KEY: ${CONCILE_ADMIN_KEY:?set CONCILE_ADMIN_KEY in a .env file}
      CONCILE_DATA_DIR: /data
    volumes:
      - ./concile:/app/concile:ro
      - concile-data:/data
    command: ["serve", "--dir", "/app/concile", "--data", "/data/db.sqlite"]
    restart: unless-stopped

volumes:
  concile-data:

This configuration builds the runner stage from the repository's Dockerfile. It then binds the container to 0.0.0.0:3000, bind-mounts your ./concile folder read-only into /app/concile, and keeps your SQLite data safe on a named volume called concile-data at /data/db.sqlite. The exact command the container runs is simply serve --dir /app/concile --data /data/db.sqlite.

bind mount port 3000 ./concile (read-only) concile:latest running serve Your browser or client concile-data volume: db.sqlite

Open the dashboard

Head over to http://localhost:3000/_dashboard. When it asks, paste in the admin key you saved in your .env file. If you're wondering why it prompts you instead of just logging you in automatically, check out the 'The dashboard is served key-less' section above! Your API is ready and waiting at http://localhost:3000, handling the sync WebSocket, /api/* HTTP traffic, and your httpAction routes seamlessly on the same origin.

Confirm data survives a restart

Your SQLite database is safely tucked away on the concile-data Docker volume rather than sitting in the container's temporary writable layer. This means you can run docker compose down && docker compose up without losing a single byte of data. The only time your data is removed is if you explicitly tell Docker to delete the volumes by running docker compose down -v.

Verify it end to end

# 1) Bring it up
docker compose up -d

# 2) Health check
curl -f localhost:3000/api/health
# {"status":"ok","functions":N,"tables":N}

# 3) Open the dashboard, paste the admin key from .env
open http://localhost:3000/_dashboard

# 4) Write some data via the dashboard or a mutation, then restart
docker compose down
docker compose up -d

# 5) Confirm the data written in step 4 is still there

Going deeper

Using Postgres

SQLite comes ready to go as our zero-configuration default. If you prefer to use Postgres, simply point serve to it using the --database-url flag or the CONCILE_DATABASE_URL environment variable. You won't need to drastically change your compose file either. Just swap out the SQLite volume for your Postgres connection string, and you're good to go.

Switching to Postgres offers a fantastic single-node durability upgrade by moving from a basic SQLite file to a fully managed database. It uses a pg_advisory_lock to act as a single-writer guard, though it's important to note this isn't for multi-node scale-out. The best part is you won't ever need to deal with app-schema migrations as your schema.ts evolves. Take a look at our Postgres guide to see a full compose example, read up on the single-writer constraint, and check out a few known limitations.

Other platforms (Railway, Fly.io)

Docker is a fantastic starting point, but it's not a strict requirement! Any platform capable of running a long-lived process with WebSocket support and a persistent disk can happily host concile serve. No matter where you deploy, the checklist remains identical. Always set your CONCILE_ADMIN_KEY securely as a platform secret rather than keeping it in a raw config file. Be sure to mount a persistent volume for your data directory, and point any health checks to GET /api/health.

railway.toml
[build]
builder = "nixpacks"

[deploy]
startCommand = "concile serve --dir ./concile --data /app/data/db.sqlite"
healthcheckPath = "/api/health"
healthcheckTimeout = 30

[[mounts]]
source = "data"
destination = "/app/data"

Just be sure to add your CONCILE_ADMIN_KEY in the service's Railway variables. Railway is great and handles TLS termination for you automatically.

Running a managed Postgres on either of these platforms works perfectly using the exact same CONCILE_DATABASE_URL setup you would use anywhere else. As an added bonus, both platforms feature an automated push path. Using concile deploy --target railway or --target fly will automatically run your platform's native CLI commands like railway up or fly deploy immediately after refreshing your codegen. For a deeper dive, check out our guide on Deploy and build.

Reverse proxy / TLS

No built-in TLS

Because concile serves plain HTTP out of the box, the serve command doesn't have built-in TLS termination. To securely handle your traffic, you'll want to place a reverse proxy like nginx, Caddy, or Traefik in front of the container. This proxy can gracefully terminate the TLS connection and then forward the traffic along to concile:3000.

Good news though! Both the sync WebSocket and your httpAction routes will proxy transparently over a standard HTTP upgrade. You won't need to mess around with any special configuration beyond setting up normal WebSocket passthrough in whatever proxy you choose.

  • Postgres: Learn how to swap out the SQLite volume for a managed Postgres database while keeping things delightfully single-node.
  • Deploy and build: Discover how concile deploy lets you do a live hot-swap onto a running serve process without any restarts using the --allow-deploy opt-in. We also cover concile build for creating a clean, self-contained binary instead of a traditional runtime-based image.
  • Scaling: When a single serve process just isn't cutting it anymore, read up on our multi-node fleet architecture powered by the --fleet flag on top of the same reliable Postgres backend.
  • Cloudflare: Check out this alternative hosting guide if you're particularly interested in taking advantage of Cloudflare's edge capabilities and scale-to-zero economics.

On this page