concile
Deploy & Operate

Deploy and build

Live hot-swap onto a running server with concile deploy, or compile a self-contained binary with concile build.

So, you have a concile serve deployment up and running (feel free to check out Self-hosting if you need a hand with that). Now you want to push a new code change to it, but you'd really rather not deal with rebuilding the container or restarting the process.

You actually have a couple of options here, plus a third route if you want to skip containers completely:

  • concile deploy takes your local concile/ changes, pushes them to a running serve, and hot-swaps them in live without any restarts.
  • concile build bundles your entire app into a single, self-contained executable. It is perfect for when you prefer shipping a binary instead of a Docker image.
  • The provisioning targets (cloudflare, docker, railway, fly, aws) are a totally different mode for concile deploy. They set up infrastructure instead of doing a hot-swap.

We'll cover all three on this page. If you know exactly what you need, feel free to jump right to it:

concile deploy: live hot-swap onto a running server

When you use concile deploy, it transpiles your local concile/ functions along with any additive schema changes, and pushes them straight to your active concile serve deployment. The server checks everything over and applies the updates atomically. This means it keeps handling requests normally the entire time, and if a deploy gets rejected, it won't ever leave things in a half-updated state.

Enable it on the server: --allow-deploy

By default, a running serve will not accept any deploys. To allow them, you just need to start it with --allow-deploy, or you can set CONCILE_ALLOW_DEPLOY=1 to opt in:

concile serve --dir concile --allow-deploy
# or, equivalently:
CONCILE_ALLOW_DEPLOY=1 concile serve --dir concile

If you forget this flag, POST /_admin/deploy won't even be registered. The request will just fall through to the generic admin router 404, looking exactly like an endpoint that doesn't exist.

Why this isn't on by default

The CONCILE_ADMIN_KEY already gives you full read and write access to your data, and both the dashboard and concile deploy use it to authenticate. If we didn't require a separate opt-in, someone getting hold of a leaked admin key could lead to remote code execution, since every deploy replaces the functions running inside your transactions. By requiring --allow-deploy, we keep "read and write my data" and "replace my running code" as two totally separate capabilities that you have to choose to enable, even if they currently share the same key.

Push a deploy

From your app's project root (where concile/ lives):

CONCILE_ADMIN_KEY=your-strong-secret concile deploy --url https://myapp.example
FlagEnv fallbackDefaultDescription
--url <url>CONCILE_DEPLOY_URL(required, unless set in the config's deploy block)The target deployment's base URL
--dir <path>noneconcileThe local concile/ directory to push
--checknoneoffFail if committed concile/_generated/ has drifted from a fresh codegen run
--dry-runnoneoffValidate (preflight + package) without pushing

We read CONCILE_ADMIN_KEY straight from your environment variables, and there is no --admin-key flag. It is required, so concile deploy will flat out refuse to run without it. You can also use the general --target and --env flags here. The command above is basically a shortcut for the default --target serve --env production. For more details, check out the CLI reference.

Run codegen yourself before a serve-target deploy

Keep in mind that the serve target pushes your tree exactly as it is on your disk. It doesn't regenerate concile/_generated/ for you. If you have changed your schema or functions, make sure to run concile codegen and commit the results before you deploy. Using --check will verify this for you and fail quickly if it spots any drift.

On success, you will see something like this:

✓ deployed via serve (production) - rev 4b3187d88b93 (7 functions, 2 changed)
  https://myapp.example

The rev you see is a content hash of the file tree you just pushed (specifically, the first 12 hex characters of its SHA-256), not a simple sequence number. If you deploy the exact same tree twice, you will get the exact same rev. You will notice the 2 changed part during a delta push, which you can read more about in what a deploy actually does below. A full push will just print (<n> functions).

Your new functions are ready to be called immediately without needing a restart, and all your existing WebSocket subscriptions will keep humming along nicely. They will reactively pick up any writes made through the newly deployed code, just like you are used to with concile dev's hot reload.

Responses and status codes

ConditionHTTP statusCLI behavior
--allow-deploy not set on the target404✗ deploy failed: deploy not enabled on target (start serve with --allow-deploy), exit 1
Wrong or missing admin key401✗ deploy failed: unauthorized, exit 1 (checked before any file is written)
Malformed payload (bad JSON, unsafe path)400✗ deploy failed: <reason>, exit 1
Destructive schema change409✗ deploy failed: <reason>, exit 1 (old version stays fully live)
Success200the two-line ✓ deployed via serve … output shown above, exit 0

Additive-only schema: destructive changes are rejected, not migrated

Right now, Concile doesn't have data migrations. Instead, diffSchema carefully compares the schema you just pushed with the server's live one, checking it field by field and table by table.

Allowed (accepted, swap proceeds):

  • Adding a brand-new table.
  • Adding a new optional field to an existing table.

Rejected (409, server stays on the previous version):

  • Removing or renaming a table.
  • Changing a table's internal table number. You shouldn't see this under normal use, as table numbers are kept stable across deploys specifically so this check doesn't block legitimate changes.
  • Removing a field from an existing table.
  • Changing a field's type. This includes changes that might look like a safe widening, like going from a string to a union that includes string, or changing any to string. The diff can't be sure a widening is safe for every single existing row (for example, an any column might hold values that aren't valid strings). Because of this, we take a conservative approach and reject any field-type changes. Being overly cautious here only fails a deploy; it can never corrupt your data, which is the whole reason this protection is here.
  • Turning an existing optional field into a required one.
  • Adding a new required field to a table that already exists. Since existing rows wouldn't have this new field, make it optional instead, and then backfill the data with a mutation if you need it populated for every row.

If any of these issues happen, you will get a deploy failed: <reason> error that tells you exactly which table and field failed and why:

✗ deploy failed: field "notes.priority" changed type string→number (destructive)

The component set is fixed at boot

Any components you've set up in concile.config.ts (like @concile/scheduler, @concile/workflow, and so on) are locked in when serve first boots. While concile deploy is great for pushing new functions and additive schema updates for those components, it cannot add or remove a component on a live server. If you need to change the component list in concile.config.ts, you will have to restart the process or redeploy your container entirely.

Functions must be top-level under concile/

The function loader we use for dev, serve, and deploy only looks at the top level. It doesn't dig into subdirectories. Make sure to keep your query, mutation, and action modules directly under concile/*.ts (like concile/items.ts). If you nest them in a subfolder, like concile/lib/items.ts, they just won't be loaded, and this happens silently both in your local dev and during a deploy.

Worked example

terminal
# 1. Start a deploy-enabled server (once)
CONCILE_ADMIN_KEY=prod-secret concile serve --dir concile --allow-deploy &

# 2. Make a change locally: add a new query, add an optional field to schema.ts
#    (edit concile/notes.ts, concile/schema.ts)

# 3. Push it live (run `concile codegen` first if schema/functions changed)
CONCILE_ADMIN_KEY=prod-secret concile deploy --url http://localhost:3000
# ✓ deployed via serve (production) - rev 4b3187d88b93 (8 functions, 2 changed)
#   http://localhost:3000

# 4. The new function is callable immediately, no restart:
curl -X POST http://localhost:3000/api/run \
  -H 'content-type: application/json' \
  -d '{"path":"notes:add","args":{"box":"b1","text":"hello"}}'

If you have any WebSocket clients subscribed to a query on the notes table before step 3, they will see the write from step 4 pushed to them reactively. The deploy that happened in between won't drop or reset their subscriptions.

Going deeper: what a deploy actually does

The six deploy targets

concile deploy --url <url> (documented above) is shorthand for one specific target: --target serve, the default. The general form is --target <name> --env <name> (plus --dry-run and --check), and the other five targets provision infrastructure instead of hot-swapping an already-running process. Target settings and credentials can live in the deploy block of concile.config.ts (see Configuration).

TargetWhat a push doesRuns codegen?Notes
serve (default)Pushes concile/ to a running serve --allow-deploy, live hot-swapNo, run concile codegen yourselfThis page, above
cloudflareReconciles wrangler.jsonc bindings, then wrangler deployYesCloudflare; needs wrangler installed, CLOUDFLARE_API_TOKEN in CI
dockerdocker compose up -d --build in your projectYesNeeds Docker installed with the daemon running
railwayrailway up (Railway builds the image itself)YesNeeds the railway CLI; RAILWAY_TOKEN in CI; optional service/environment settings
flyfly deploy (Fly builds the image itself)YesNeeds flyctl; FLY_API_TOKEN in CI; optional app/region settings
awsaws apprunner start-deployment against an existing App Runner serviceYesNeeds the aws CLI and a serviceArn setting; AWS_ACCESS_KEY_ID or AWS_PROFILE in CI

We never bundle provider CLIs into concile itself. Instead, each target simply shells out to the CLI you already have installed. If it's missing, you will get a clear install hint during the preflight check. Just remember that only the serve target actually uses the additive-schema gate we described earlier. The other provisioning targets ship a brand new build or image, so there is nothing to gate, because the platform's own rollout handles replacing the process.

Deploying from CI (GitHub Actions)

Think of CI as just another caller running the same command. The workflow below runs the exact same concile deploy that you would run locally, pulling credentials from your repository secrets instead of your environment.

.github/workflows/deploy.yml
name: deploy
on:
  pull_request:
  push: { branches: [main] }
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - run: bun run test
      # PRs: verify committed codegen is current; exits before any target preflight, so
      # it needs no secrets. Add --dry-run to also validate target preflight/packaging
      # (the serve target's preflight then wants CONCILE_DEPLOY_URL + CONCILE_ADMIN_KEY).
      - if: github.event_name == 'pull_request'
        run: bunx concile deploy --check
      # main: the real deploy (serve target).
      - if: github.ref == 'refs/heads/main'
        run: bunx concile deploy
        env:
          CONCILE_DEPLOY_URL: ${{ secrets.CONCILE_DEPLOY_URL }}
          CONCILE_ADMIN_KEY: ${{ secrets.CONCILE_ADMIN_KEY }}

Here are three reasons why this workflow is perfectly safe to copy:

  • It never hangs. concile deploy figures out its interactive mode from stdin.isTTY and the CI environment variable (GitHub Actions automatically sets CI=true on every runner). Because of this, if a credential is missing, it will fail fast with a helpful message and a non-zero exit rather than sitting there prompting you forever.
  • --check gates codegen drift. It will exit 1 if concile/_generated/ doesn't perfectly match a fresh codegen run. This is especially important for the serve target since a serve deploy will never regenerate it. Adding --dry-run goes a step further by validating the target's preflight and package steps without actually pushing anything.
  • Exit codes are real. A rejected schema change, a failed --check, or a provider CLI error will always exit 1 and fail the job just like any standard CI step.

If you are using a provisioning target, you just need to swap out the deploy line and your secrets. For example, use bunx concile deploy --target cloudflare with a CLOUDFLARE_API_TOKEN (and remember to add an npm i -D wrangler install step). Or use --target railway with RAILWAY_TOKEN, --target fly with FLY_API_TOKEN, and so forth, as shown in the table above. Make sure your tokens always come from CI secrets, because the interactive login flows of provider CLIs really have no place in a headless runner.

concile build: one self-contained binary

Using concile build will compile your entire app, the Bun runtime, the Concile engine, bun:sqlite, your concile/ functions and schema, plus any composed components, directly into a single executable using bun build --compile. When it comes time to deploy, all you need is that binary and a data directory. You won't need a bun or node install, no node_modules, and no concile/ source tree at all.

ComponentBundled into the binary?
Bun runtimeYes
Concile engine (query engine, transactor, sync protocol)Yes
SQLite (bun:sqlite)Yes, built into Bun
Your concile/ functions and schemaYes, embedded via a generated static-import entrypoint
Composed components (@concile/scheduler, @concile/workflow, …)Yes, whatever concile.config.ts composes
DashboardOptional, included by default (--no-dashboard to exclude)
SQLite database fileNo, it lives on disk under --data-dir, external to the binary

Check prerequisites

You will need a concile/ directory (for your schema and functions) and, optionally, a concile.config.ts file right next to it. This is exactly the same layout that concile dev uses. There is no concile init scaffolder here. If your app uses any composed components, just install them as ordinary dependencies (like bun add @concile/scheduler @concile/workflow). Also, concile build handles its own codegen internally, so you do not have to run a separate concile codegen step beforehand.

Build

concile build

Output: ./concile-server (the default --outfile).

concile build --outfile ./dist/my-backend
FlagDefaultDescription
--dir <path>concileApp's concile/ directory to build
--outfile <path>./concile-serverOutput path
--target <platform>current platformOne of linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows-x64
--no-dashboarddashboard includedExclude the dashboard UI from the binary
--verboseoffStream the underlying bun build --compile output instead of suppressing it

On success, concile build prints the output path and its size:

✓ built ./concile-server (58MB)

Run it

CONCILE_ADMIN_KEY=your-strong-secret ./concile-server --port 3000 --hostname 0.0.0.0 --data-dir ./data

Just like with concile serve, the CONCILE_ADMIN_KEY is completely required here. The binary will fail fast (exit 1) if you don't set it, and it will never automatically generate one for you. This setup is meant for production, just like serve.

FlagEnv fallbackDefaultDescription
--portPORT3000Port to listen on
--hostnamenone0.0.0.0Address to bind to
--data-dirnone./dataDirectory holding the SQLite database (db.sqlite)
--database-urlCONCILE_DATABASE_URLSQLitePostgres connection string, same flag serve accepts. Unset uses the embedded SQLite adapter

Otherwise, the binary is totally runtime-flag-compatible with concile serve. It has the same required admin key fail-fast behavior, and it handles graceful shutdowns on SIGTERM or SIGINT perfectly (it will stop the listener, securely close the database, and then exit 0).

Right after the listener comes up, the binary will write exactly one JSON line to stdout:

{"ready":true,"port":3000,"url":"http://0.0.0.0:3000"}

This is really helpful for a parent process (like Electron, Tauri, or a supervisor script) to read stdout and know exactly when the server is ready and which port it actually bound to. This is especially relevant if you are using --port 0 or $PORT to pick an ephemeral port. Nothing else will be printed to stdout before this line. You should treat "first line of stdout" as the strict contract, rather than looking for a "first line matching some pattern".

No live hot-swap in a compiled binary

Unlike concile serve --allow-deploy, a compiled binary doesn't expose POST /_admin/deploy. Everything embedded at compile time is fixed for the life of that binary:

  • Functions and schema: shipping a change means rebuilding and redeploying the binary, not concile deploy.
  • The composed component set: adding @concile/workflow to concile.config.ts requires a rebuild, same as it requires a restart under serve.

If you really need live hot-swap functionality, you should run concile serve --allow-deploy instead, as we talked about above. The single binary trades that feature away so you can have a zero-dependency, single-file deployment artifact.

Other limitations

  • Single instance. SQLite strictly requires exclusive file access, meaning you can only have one binary process per data directory. The only exception is if you pass --database-url to point it at Postgres. In that case, the usual single-writer advisory-lock rules from Postgres apply instead.
  • No concile init scaffolder. You have to bring your own concile/ directory, because concile build will not generate one for you.

Worked example: fixture app with a component, then cross-compiled

This matches what we do in our shipped end-to-end test:

terminal
concile build --dir concile --outfile ./concile-server --no-dashboard
# ✓ built ./concile-server (58MB)

CONCILE_ADMIN_KEY=e2e ./concile-server --port 3599 --hostname 127.0.0.1 --data-dir ./data
# {"ready":true,"port":3599,"url":"http://127.0.0.1:3599"}

curl -X POST http://127.0.0.1:3599/api/run \
  -H 'content-type: application/json' \
  -d '{"path":"notes:add","args":{"box":"a","text":"compiled"}}'
# {"value":null,"committed":true,...}

# Cross-compile the same app for a Linux server:
concile build --dir concile --target linux-x64 --outfile ./dist/server-linux --no-dashboard
# ✓ built ./dist/server-linux (95MB)

Going deeper: how the build works

Cross-compilation

concile build --target linux-x64 --outfile ./dist/server-linux

For the windows-x64 target, it will automatically append .exe to your --outfile if you didn't include it yourself.

Deployment patterns for the binary

CONCILE_ADMIN_KEY=your-strong-secret ./concile-server --hostname 0.0.0.0 --port 8080 --data-dir /var/lib/concile

Choosing between deploy, build, and Docker self-hosting

concile deployconcile buildDocker (bind-mount)
Restart needed to ship a change?No, live hot-swapYes, rebuild and redeploy the binaryYes, restart the container
Schema changesAdditive only, validated, atomicWhatever the new build embeds (no live validation, it's a fresh process)Same as deploy if you also run deploy against it; a plain restart has no gate
Component set changesRequires a restartRequires a rebuildRequires a restart
Deployment artifactNone, pushes to an existing processOne native executableDocker image plus bind-mounted concile/
Runtime dependencyserve already runningBun/Node only to build; the binary itself needs nothingDocker
PostgresWhatever the target serve was started with--database-url flag, same as serveSame as serve

In practice, these options aren't mutually exclusive at all. You could easily run concile serve --allow-deploy inside a container and use concile deploy against it for all your day-to-day shipping. Then, you can just fall back to fully rebuilding the image on those rare occasions when you actually need to change the composed component set.

  • Self-hosting with Docker: the serve deployment concile deploy pushes onto, and the bind-mounted-image alternative to the standalone binary.
  • Postgres: --database-url/CONCILE_DATABASE_URL, accepted by both serve and the compiled binary.
  • Cloudflare: concile deploy --target cloudflare, and the general --target/--env provisioning model.
  • Local dev: concile dev, the hot-reload loop this page's deploy and build both build on.

On this page