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 deploytakes your localconcile/changes, pushes them to a runningserve, and hot-swaps them in live without any restarts.concile buildbundles 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 forconcile 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- The six deploy targets
- Deploying from CI (GitHub Actions)
concile build: one self-contained binary- Choosing between
deploy,build, and Docker has a handy comparison table if you are trying to figure out which one is right for you.
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 concileIf 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| Flag | Env fallback | Default | Description |
|---|---|---|---|
--url <url> | CONCILE_DEPLOY_URL | (required, unless set in the config's deploy block) | The target deployment's base URL |
--dir <path> | none | concile | The local concile/ directory to push |
--check | none | off | Fail if committed concile/_generated/ has drifted from a fresh codegen run |
--dry-run | none | off | Validate (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.exampleThe 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
| Condition | HTTP status | CLI behavior |
|---|---|---|
--allow-deploy not set on the target | 404 | ✗ deploy failed: deploy not enabled on target (start serve with --allow-deploy), exit 1 |
| Wrong or missing admin key | 401 | ✗ 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 change | 409 | ✗ deploy failed: <reason>, exit 1 (old version stays fully live) |
| Success | 200 | the 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
stringto a union that includesstring, or changinganytostring. The diff can't be sure a widening is safe for every single existing row (for example, ananycolumn 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
# 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
concile deploy runs three steps locally, then hands off to one remote step:
- Transpiles every
.tsfile underconcile/using esbuild'stransform(notbundle). This strips out the TypeScript types but leaves your import specifiers completely untouched. So, a bare import likeimport { v } from "@concile/values"passes through exactly as it is, and it resolves against the target server's ownnode_modules, not what you have locally. Relative imports resolve within the pushed file tree itself. This is exactly why a deploy target needs the same@concile/*packages installed as your app does (the Docker image already handles this, which you can read about in Self-hosting). Also, it does not refreshconcile/_generated/; that isconcile codegen's job, so remember to run it yourself before deploying. - Probes
GET /_admin/deploy/modulesto check the server's current per-module content hashes (lowercase-hex SHA-256 per pushed path). If you have an older server, or one with deploy disabled, it won't answer. In that case, the CLI just falls back to doing a full-tree push. - Posts a delta to
/_admin/deploy, usingAuthorization: Bearer $CONCILE_ADMIN_KEY. Modules whose hashes differ are sent aschanged({path, code}), while matching ones go asunchanged({path, sha256}). This way, an unchanged module never crosses the wire twice. Without a probe base, it posts the full{files: [{path, code}]}tree instead. If the server says the delta's base is stale (maybe another deploy landed in between), the CLI simply retries once with the full tree. - The server validates and applies it, which we explain in the next section. This is actually the only step that runs remotely.
On the server, applyDeploy does the following, in this exact order:
- Writes the pushed tree to disk under a per-revision directory
(
<deployRoot>/<rev>/functions/). It actively rejects any path that is absolute or contains a..segment, which acts as a nice path-traversal guard on the payload. - Loads and pushes it through the exact same
loadFunctionsDir → pushpipeline that it uses at boot. It does this against the deployment's fixed, already-composed component set and its current table numbers. Existing tables, whether they are app tables or component tables, keep the numbers they already have. Only genuinely new tables get fresh ones. - Diffs the schema, following the additive-only rules we covered earlier. If it spots a destructive change to a table or field, the deploy gets rejected right here, and absolutely nothing is swapped yet.
- Only if both of those succeed, it atomically swaps four things in sequence, with no
awaitbetween them:runtime.setModules(...),runtime.setTableNumbers(...),setRoutes(...)(forhttpActionroutes), and the admin API's live schema (adminApi.setSchema(...)). Since there is noawaitin this sequence, no request will ever see a weird state where only some of these have been updated.
If there is a load error or a schema rejection, the running deployment is left completely untouched. Your previous version just keeps serving every request the whole time. This is what we mean by "atomic". It isn't just that the swap is instantaneous (which it is), but that validation is guaranteed to fully complete without changing any running state before the very first field is ever swapped.
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).
| Target | What a push does | Runs codegen? | Notes |
|---|---|---|---|
serve (default) | Pushes concile/ to a running serve --allow-deploy, live hot-swap | No, run concile codegen yourself | This page, above |
cloudflare | Reconciles wrangler.jsonc bindings, then wrangler deploy | Yes | Cloudflare; needs wrangler installed, CLOUDFLARE_API_TOKEN in CI |
docker | docker compose up -d --build in your project | Yes | Needs Docker installed with the daemon running |
railway | railway up (Railway builds the image itself) | Yes | Needs the railway CLI; RAILWAY_TOKEN in CI; optional service/environment settings |
fly | fly deploy (Fly builds the image itself) | Yes | Needs flyctl; FLY_API_TOKEN in CI; optional app/region settings |
aws | aws apprunner start-deployment against an existing App Runner service | Yes | Needs 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.
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 deployfigures out its interactive mode fromstdin.isTTYand theCIenvironment variable (GitHub Actions automatically setsCI=trueon 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. --checkgates codegen drift. It will exit 1 ifconcile/_generated/doesn't perfectly match a fresh codegen run. This is especially important for theservetarget since a serve deploy will never regenerate it. Adding--dry-rungoes a step further by validating the target'spreflightandpackagesteps 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.
| Component | Bundled into the binary? |
|---|---|
| Bun runtime | Yes |
| Concile engine (query engine, transactor, sync protocol) | Yes |
SQLite (bun:sqlite) | Yes, built into Bun |
Your concile/ functions and schema | Yes, embedded via a generated static-import entrypoint |
Composed components (@concile/scheduler, @concile/workflow, …) | Yes, whatever concile.config.ts composes |
| Dashboard | Optional, included by default (--no-dashboard to exclude) |
| SQLite database file | No, 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 buildOutput: ./concile-server (the default --outfile).
concile build --outfile ./dist/my-backend| Flag | Default | Description |
|---|---|---|
--dir <path> | concile | App's concile/ directory to build |
--outfile <path> | ./concile-server | Output path |
--target <platform> | current platform | One of linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows-x64 |
--no-dashboard | dashboard included | Exclude the dashboard UI from the binary |
--verbose | off | Stream 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 ./dataJust 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.
| Flag | Env fallback | Default | Description |
|---|---|---|---|
--port | PORT | 3000 | Port to listen on |
--hostname | none | 0.0.0.0 | Address to bind to |
--data-dir | none | ./data | Directory holding the SQLite database (db.sqlite) |
--database-url | CONCILE_DATABASE_URL | SQLite | Postgres 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/workflowtoconcile.config.tsrequires a rebuild, same as it requires a restart underserve.
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-urlto point it at Postgres. In that case, the usual single-writer advisory-lock rules from Postgres apply instead. - No
concile initscaffolder. You have to bring your ownconcile/directory, becauseconcile buildwill 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:
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
- Refreshes codegen: It runs
loadFunctionsDir → push → writeGeneratedexactly likedeploydoes, ensuring that your app's ownimport "./_generated/server"can resolve properly once everything is bundled. - Generates a static-import entrypoint: Since
bun build --compileonly bundles code that is reached via staticimportstatements, and there is no filesystem to dynamically scan inside a compiled binary,concile buildgets creative. It writes a temporaryentry.tsfile that statically imports every singleconcile/module by its absolute path. It does the same for the schema,concile.config.tsif you have one, and every dashboard asset (usingimport x with { type: "file" }) if the dashboard is included. After that, it reconstructs the exact{schema, modules}shape thatloadFunctionsDirusually returns at runtime, and feeds it torunBinaryServer. This is the exact same boot core thatconcile serveuses, just fed with statically imported modules rather than scanning a dynamic directory. - Shells out to
bun build --compile --minify: It does this usingnode:child_process.spawnSync, which means it works perfectly whether you invoke the CLI under Bun or Node. The only thing is that the compile step itself still needs Bun to be on yourPATH. - Cleans up the generated entrypoint.
Why we skip --bytecode: The generated entrypoint boots up with a top-level await to make
sure the runtime fully starts before it begins serving. Unfortunately, bun build --compile --bytecode will flat out reject top-level await. Since cold-start speed really doesn't matter much
for a long-running self-hosted server binary, concile build simply omits --bytecode instead of
trying to restructure the entire entrypoint around it.
Cross-compilation
concile build --target linux-x64 --outfile ./dist/server-linuxconcile build --target linux-arm64 --outfile ./dist/server-linux-arm64concile build --target darwin-arm64 --outfile ./dist/server-macosconcile build --target darwin-x64 --outfile ./dist/server-macos-x64concile build --target windows-x64 --outfile ./dist/server-windows.exeFor 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/concileIf you want an alternative to the bind-mounted image we discussed in
Self-hosting, you can cross-compile for Linux and then copy just the
binary into a distroless base. This gives you an image with no Bun, no node_modules, and no source
code at all.
FROM gcr.io/distroless/base-debian12
COPY dist/concile-server /concile-server
EXPOSE 3000
VOLUME /data
ENTRYPOINT ["/concile-server", "--hostname", "0.0.0.0", "--data-dir", "/data"]concile build --target linux-x64 --outfile ./dist/concile-server
docker build -f Dockerfile.binary -t my-app-binary .
docker run -p 3000:3000 -e CONCILE_ADMIN_KEY=your-strong-secret -v concile-data:/data my-app-binaryTools like Tauri or Electrobun can actually spawn the binary as a child process. They can then parse
its {"ready":...} stdout line to figure out the exact URL to connect to. This gives you fantastic
process isolation from the app's own runtime.
[Unit]
Description=Concile Server
After=network.target
[Service]
Type=simple
Environment=CONCILE_ADMIN_KEY=your-strong-secret
ExecStart=/opt/concile/concile-server --data-dir /var/lib/concile --hostname 0.0.0.0
Restart=on-failure
User=concile
[Install]
WantedBy=multi-user.targetChoosing between deploy, build, and Docker self-hosting
concile deploy | concile build | Docker (bind-mount) | |
|---|---|---|---|
| Restart needed to ship a change? | No, live hot-swap | Yes, rebuild and redeploy the binary | Yes, restart the container |
| Schema changes | Additive only, validated, atomic | Whatever 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 changes | Requires a restart | Requires a rebuild | Requires a restart |
| Deployment artifact | None, pushes to an existing process | One native executable | Docker image plus bind-mounted concile/ |
| Runtime dependency | serve already running | Bun/Node only to build; the binary itself needs nothing | Docker |
| Postgres | Whatever the target serve was started with | --database-url flag, same as serve | Same 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.
Related
- Self-hosting with Docker: the
servedeploymentconcile deploypushes onto, and the bind-mounted-image alternative to the standalone binary. - Postgres:
--database-url/CONCILE_DATABASE_URL, accepted by bothserveand the compiled binary. - Cloudflare:
concile deploy --target cloudflare, and the general--target/--envprovisioning model. - Local dev:
concile dev, the hot-reload loop this page'sdeployandbuildboth build on.