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.

You've got a concile serve deployment running (see Self-hosting). Now you want to ship a code change to it without rebuilding the container or restarting the process.

There are two ways to do that, plus a third path if you'd rather skip containers entirely:

  • concile deploy pushes local concile/ changes to an already-running serve and hot-swaps them in live, with no restart.
  • concile build compiles your whole app into one self-contained executable, for when you'd rather ship a binary than a Docker image.
  • The provisioning targets (cloudflare, docker, railway, fly, aws), a different concile deploy mode entirely. They provision infrastructure, they don't hot-swap.

This page covers all three. If you already know what you're looking for, jump straight there:

concile deploy: live hot-swap onto a running server

concile deploy transpiles your local concile/ functions and additive schema changes, then pushes them to a running concile serve deployment. The server validates everything and applies it atomically: it keeps serving requests the whole time, and a rejected deploy never leaves it half-updated.

Enable it on the server: --allow-deploy

A running serve doesn't accept deploys by default. Start it with --allow-deploy, or set CONCILE_ALLOW_DEPLOY=1, to opt in:

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

Without this flag, POST /_admin/deploy isn't registered at all. The request falls through to the generic admin-router 404, indistinguishable from a nonexistent endpoint.

Why this isn't on by default

CONCILE_ADMIN_KEY already grants full read and write access to your data (the dashboard and concile deploy both authenticate with it). Without a separate opt-in, a leaked admin key would mean remote code execution: every deploy replaces the functions that run inside the transaction. Requiring --allow-deploy keeps "read/write my data" and "replace my running code" as two distinct, deliberately opted-into capabilities, even though today they share one 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

CONCILE_ADMIN_KEY is read from the environment (there's no --admin-key flag), and it's required. concile deploy refuses to run without it. The general --target/--env flags also apply here (this command form is the default --target serve --env production); see the CLI reference.

Run codegen yourself before a serve-target deploy

The serve target pushes your tree exactly as it sits on disk. It does not regenerate concile/_generated/. If your schema or functions changed, run concile codegen (and commit the result) before deploying. --check verifies exactly this and fails fast on drift.

On success, verbatim:

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

rev is a content hash of the pushed file tree (the first 12 hex characters of its SHA-256), not a sequence number. Deploying the exact same tree twice produces the same rev. The 2 changed part appears on a delta push (see what a deploy actually does below); a full push prints just (<n> functions). The new functions are callable immediately, with no restart, and existing WebSocket subscriptions keep working. They reactively pick up writes made through the newly deployed code, exactly as if the change had been made via 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

There are no data migrations in Concile today. diffSchema compares your pushed schema against the server's live one, 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. This shouldn't happen under normal use: table numbers are stable across a deploy specifically so this check never fires on a legitimate change.
  • Removing a field from an existing table.
  • Changing a field's type, including what looks like a safe widening, like string to a union that includes string, or any to string. The diff can't verify a "widening" is actually sound against every existing row (an any column may hold values that aren't valid strings), so v1 takes the conservative position: any field-type change is rejected. Over-rejecting only fails a deploy. It can never corrupt data, which is the property this gate exists to protect.
  • Turning an existing optional field required.
  • Adding a new required field to an existing table. Existing rows don't have it, so make it optional instead, and backfill via a mutation if you need every row populated.

Any of these come back as deploy failed: <reason> describing 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

Components composed via concile.config.ts (@concile/scheduler, @concile/workflow, and any others) are whatever was declared when serve booted. concile deploy can push new functions and additive schema against that fixed component set, but it can't add or remove a component on a live server. Changing the component list in concile.config.ts requires restarting the process, or redeploying the container.

Functions must be top-level under concile/

The function loader (used by dev, serve, and deploy alike) is top-level only. It does not recurse into subdirectories. Keep your query, mutation, and action modules directly under concile/*.ts (for example concile/items.ts), not nested under a subfolder like concile/lib/items.ts. Nested modules are silently not loaded, in a deploy exactly as in local dev.

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"}}'

Any WebSocket client subscribed to a query touching the notes table before step 3 sees the write from step 4 pushed to it reactively. The deploy in between didn't drop or reset the subscription.

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

Provider CLIs are never bundled into concile; each target shells out to the one you already have installed, and fails preflight with a clear install hint if it's missing. Only the serve target uses the additive-schema gate described above. The provisioning targets ship a whole new build/image, so there's nothing to gate: the platform's own rollout replaces the process.

Deploying from CI (GitHub Actions)

CI is just another caller of the same command: the workflow below runs the identical concile deploy a human runs locally, with credentials coming from repository secrets.

.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 }}

Three properties make this workflow safe to copy:

  • It never hangs. concile deploy derives its interactive mode from stdin.isTTY and the CI environment variable (GitHub Actions sets CI=true on every runner), so a missing credential fails fast with an actionable message and a non-zero exit instead of prompting.
  • --check gates codegen drift. It exits 1 if concile/_generated/ doesn't match a fresh codegen run, which matters doubly for the serve target since a serve deploy never regenerates it. --dry-run additionally runs the target's preflight and package steps without pushing.
  • Exit codes are real. A rejected schema change, a failed --check, or a provider CLI error all exit 1 and fail the job like any other CI step.

For a provisioning target, swap the deploy line and secrets: bunx concile deploy --target cloudflare with CLOUDFLARE_API_TOKEN (plus a npm i -D wrangler install step), --target railway with RAILWAY_TOKEN, --target fly with FLY_API_TOKEN, and so on per the table above. Tokens always come from CI secrets; the provider CLIs' interactive login flows have no place in a headless runner.

concile build: one self-contained binary

concile build compiles your entire app, the Bun runtime, the Concile engine, bun:sqlite, your concile/ functions and schema, and any composed components, into a single executable via bun build --compile. At deployment time you need nothing but the binary and a data directory: no bun/node install, no node_modules, no concile/ source tree.

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 need a concile/ directory (schema and functions) and, optionally, a concile.config.ts next to it: the same layout concile dev uses. There's no concile init scaffolder. If your app composes components, install them as ordinary dependencies (bun add @concile/scheduler @concile/workflow). concile build runs its own codegen internally, so there's no separate concile codegen step to run first.

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

CONCILE_ADMIN_KEY is required, exactly like concile serve. The binary fails fast (exit 1) if it isn't set, and it's never auto-generated. This is production-facing, same as 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

The binary is otherwise runtime-flag-compatible with concile serve: the same required-admin-key fail-fast behavior, and graceful shutdown on SIGTERM/SIGINT (stop the listener, close the database, exit 0).

Immediately after the listener is up, the binary writes exactly one JSON line to stdout:

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

This exists for a parent process, like Electron, Tauri, or a supervisor script, to read stdout and know precisely when the server is ready and which port it actually bound (relevant when --port 0 or $PORT picks an ephemeral port). Nothing else is printed to stdout before this line. Treat "first line of stdout" as the contract, not "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 need live hot-swap, run concile serve --allow-deploy instead (see above). The single binary trades that away for a zero-dependency, single-file deployment artifact.

Other limitations

  • Single instance. SQLite requires exclusive file access, so it's one binary process per data directory (unless you pass --database-url to point it at Postgres, in which case the usual single-writer advisory-lock rules from Postgres apply instead).
  • No concile init scaffolder. You bring your own concile/ directory. concile build doesn't generate one.

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

This mirrors the 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

A windows-x64 target automatically appends .exe to --outfile if you didn't already include it.

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 aren't mutually exclusive: you can run concile serve --allow-deploy in a container and use concile deploy against it for day-to-day shipping, falling back to rebuilding the image only when you 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