Cloudflare
concile deploy --target cloudflare provisions a Durable-Object-native host via wrangler.
Concile gives you two cool ways to deploy on Cloudflare. Most folks will want to go with the first approach since it's the smoothest. The Containers path is still experimental and doesn't handle scheduled work yet, but you can totally read more about it below if you're curious!
| Durable-Object-native (this page's main path) | Containers (alternative) | |
|---|---|---|
| Command | concile deploy --target cloudflare | hand-wired, no concile deploy integration |
| Storage | DO-SQLite (per-object) | R2 (object-store substrate) |
| Scheduled functions / crons / triggers | Fire: the DO's own alarm wakes them | Do not fire: container stops ~5s after idle |
| Status | Supported target | Experimental |
The recommended path: Durable-Object-native
When you run concile deploy --target cloudflare, we set up a Durable Object host from
@concile/runtime-cloudflare using wrangler deploy. If you aren't familiar with it, a Durable
Object is Cloudflare's way of handling state.
It gives you a globally addressable, single-instance object that has its own storage and runs on a
single thread.
In this setup, one Durable Object takes care of everything. It acts as the OCC writer, manages your DO-SQLite store, handles all hibernatable WebSockets, and even triggers your scheduled functions and crons. The best part is that it wakes itself up for those scheduled tasks, even if it goes idle between requests! Keep in mind that for v1, this is a single global DO rather than a sharded fleet. If you're looking to scale out later, check out our Scaling guide for details on the paid-tier multi-shard options.
Install wrangler
npm i -D wranglerAuthenticate locally by running wrangler login. If you're setting this up in CI, you'll want to
use environment variables instead. Just set CLOUDFLARE_API_TOKEN and, usually,
CLOUDFLARE_ACCOUNT_ID. Make sure to avoid running wrangler login in your CI environment!
Write a wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-app",
"main": "worker.ts",
"compatibility_date": "2024-11-27",
// node:crypto (fingerprint/resume hashing) needs nodejs_compat.
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "CONCILE_DO", "class_name": "ConcileDO" }]
},
"migrations": [
// DO-SQLite needs the SQLite-backed class migration tag.
{ "tag": "v1", "new_sqlite_classes": ["ConcileDO"] }
]
}Your worker source file only needs a few simple lines of code: export class ConcileDO extends ConcileDurableObject { … } and export default createWorkerHandler("CONCILE_DO"). Just note that
concile deploy won't generate this file for you. Instead, it looks at your existing
wrangler.jsonc and carefully adds any necessary bindings without ever overwriting the fields you
wrote yourself.
Just like with concile serve, you will also need a concile/ directory that contains a committed
_generated/ folder. Don't worry about keeping it perfectly up to date, as concile deploy will
automatically refresh your generated code before packaging everything up.
Deploy
concile deploy --target cloudflare --env productionWhen you run concile deploy --target cloudflare, here is what happens behind the scenes:
- It double checks your
wrangler.jsoncfile. Ifdurable_objects.bindings,migrations, orcompatibility_flagsare missing, it adds them for you. If everything is already there, it leaves your file completely untouched, keeping all your comments and formatting intact! - It runs
wrangler deployfor you. If you havewranglerEnvset in your configuration underenvironments.<name>, it will pass--env <name>right along. - Finally, it parses wrangler's output and prints your shiny new deployed URL to the terminal.
If you want to test things out without making any real changes, you can use the --dry-run flag.
This runs through all the preflight checks and packaging steps, like validating wrangler and
refreshing your generated code, but skips the actual deployment. It is super handy for checking a
PR! You can also use the --check flag, which will fail if your committed concile/_generated/
folder is out of sync with a fresh code generation run.
Configure environments in concile.config.ts:
import { defineConfig } from "@concile/component";
export default defineConfig({
components: [], // your composed components, if any
deploy: {
targets: {
cloudflare: {
provider: "cloudflare",
environments: {
production: {}, // wrangler deploy
staging: { wranglerEnv: "staging" }, // wrangler deploy --env staging
},
},
},
},
});Set the admin key as a secret
wrangler secret put CONCILE_ADMIN_KEYMake sure to set your CONCILE_ADMIN_KEY, as it is strictly required. The Durable Object will
immediately fail if the admin key is missing. Since concile deploy doesn't handle secrets
directly, running wrangler secret put is just a quick, one-time step you'll do on your own. It is
exactly like how you wouldn't want to put your CLOUDFLARE_API_TOKEN inside your wrangler.jsonc
file!
Optional configuration
If your application makes use of file storage, you can easily turn on an R2 binding like this:
cloudflare: {
provider: "cloudflare",
r2: true,
r2BucketName: "my-app-storage", // optional, defaults to "concile-storage"
},When you run concile deploy --target cloudflare, it will automatically add an r2_buckets binding
to your wrangler.jsonc file. All you need to do is create the bucket yourself one time:
wrangler r2 bucket create my-app-storageDurable Objects like to stay put and they will not move around once they are created. By default,
they will set up shop close to whoever visits them first. If you prefer, you can set
CONCILE_DO_LOCATION_HINT as a Worker environment variable to choose a specific location. Just use
one of the 11 Cloudflare region codes, like enam. Keep in mind that only the very first request
after a fresh deployment will actually pay attention to this hint!
What works
| Reactive queries/mutations, WebSocket sync | We offer full support here! Both the writer and the subscription index live in the exact same object, meaning you don't have to deal with any extra network hops. |
| Scheduled functions, crons, triggers | These work wonderfully. The DO has its own built-in alarm that will wake up your timers even if it is idle or hibernating. |
| File storage | Fully supported using the R2 binding we mentioned above. |
| Scale | For v1, you get a single global DO rather than a sharded fleet. If you need more power, take a look at our Scaling guide to learn about the paid-tier multi-shard router. |
Global tables (D1)
When you add .global() to a table definition, it stores that table in Cloudflare
D1 (which is a shared relational database) rather than
inside a Durable Object's local store. This means you get access to the exact same data regardless
of which shard or DO is running your query! This is perfect for information that needs to be
globally unique or consistent across your app, like a users table organized by email or an orgs
table organized by a slug. It pairs beautifully with our paid-tier multi-shard router, and you can
even use it on a single-DO host by simply wiring up a D1 binding.
Just remember that .global() and .shardKey() cannot be used together. A table can be sharded or
global, but never both! If you try to declare both, you will get an error when building your schema.
import { defineSchema, defineTable, v } from "@concile/values";
export default defineSchema({
accounts: defineTable({
email: v.string(),
name: v.string(),
})
// { unique: true } on a .global() table is a REAL global-unique constraint
// (a D1 CREATE UNIQUE INDEX), enforced across every shard.
.index("by_email", ["email"], { unique: true })
.global(),
});The great thing is that reads and writes are automatically routed to D1, and your function body will
look exactly the same as it does for any other table! We support ctx.db.get(id) as well as
equality index queries like .eq(field, value) and optionally .take(n). If you have a mutation
that inserts a global row and reads it back right away, it will successfully see its own write.
Also, once a row is written through one shard-DO, it becomes instantly readable through any other
shard right after the commit! If a unique constraint is violated, the mutation will be rejected and
give you a helpful error naming the specific table and field.
If you want to subscribe to a .global() table, you can do so easily using the same useQuery hook
as your other tables. Behind the scenes, this works using a poller instead of a commit-time push. A
DO with at least one active global subscription will check a D1 version counter roughly every 2
seconds, and it only pushes updates when something actually changes. If a DO doesn't have any
subscriptions, it won't poll at all! You can expect global changes to show up within that two-second
window, but if your writing client's UI needs to feel instantaneous, we highly recommend adding an
optimistic update.
We believe in honest boundaries, so any limitations will fail quickly rather than failing silently:
- No co-writes. A single mutation can write to either global tables or sharded/root tables, but it can never write to both. A D1 write cannot join a shard's local transaction, so allowing both could lead to partial commits. The fix is easy, just split them into two separate mutations!
- Equality-only queries. If you try to use range comparisons,
.order(), filters, or.paginate()on a.global()table, we will reject it with a clear and friendly error message. { unique: true }means D1. If you try to use this option on a non-global table's index, it will be rejected when loading the schema. A per-shard store just cannot enforce global uniqueness.- Row policies aren't supported here yet. If you try to apply an authz read or write policy on a
.global()table, we will politely reject it with an error instead of silently skipping it. - Cloudflare only. If you don't have a D1 binding, any read or write to a
.global()table will fail and let you know which table caused the issue. You are completely free to declare.global()in a schema that you run locally, but you won't be able to interact with those tables outside of Cloudflare.
For the wiring part, setting up the d1_databases binding is a manual step right now. Simply create
the database once using wrangler d1 create <name>, add { "binding": "DB", "database_name": "...", "database_id": "..." } to your wrangler.jsonc file, and pass it through your DO subclass's
appConfig as d1: bindingD1Client(env.DB) (which you can import from @concile/docstore-d1). We
handle the rest! The D1 tables and indexes for all of your .global() tables are automatically
created the very first time you boot with the binding present. No migration steps required!
CI: GitHub Actions
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: npm i -D wrangler
- if: github.event_name == 'pull_request'
run: bunx concile deploy --check --dry-run --target cloudflare
- if: github.ref == 'refs/heads/main'
run: bunx concile deploy --target cloudflare --env production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CONCILE_ADMIN_KEY: ${{ secrets.CONCILE_ADMIN_KEY }}Every target's preflight check needs your credentials up front. If you are running in a
non-interactive mode like CI (CI=true), it will fail fast and give you a helpful error message
so you know exactly what to fix. It will never prompt you or hang indefinitely waiting for input!
The alternative path: Cloudflare Containers (experimental)
If you really want to use the exact same portable concile serve image you would run anywhere else
(with SQLite or Postgres storage options), you can run it inside a Cloudflare Container instead of a
Durable Object! Just keep in mind that this approach is hand-wired and does not integrate with
concile deploy. There are also some specific behaviors you should know about before making your
choice.
Scheduled work does not fire
When using this path, scheduled functions, crons, triggers, and the file-storage reaper will not fire on their own. Cloudflare is designed to stop the container about 5 seconds after the last request to help you scale down to zero. This means a cron scheduled for 3:00 AM will only run if a user happens to send a request at exactly 3:00 AM. We recommend this path only for purely request-driven apps that do not rely on scheduled tasks.
This setup relies on Workers acting as a stateless router, Containers to run the concile serve
image, and R2 as your object-store substrate. Since container storage is temporary, R2 must be your
single source of truth. To make this work, you will need a Workers Paid plan (which is around $5 per
month, as Containers and DOs do not have a free tier), an R2 bucket, and a Docker setup capable of
building for linux/amd64. If you are using Apple Silicon, you will need to cross-build by running
docker build --platform linux/amd64. Since Containers do not support bind mounts, your application
has to be baked right into the image:
FROM concile:latest
COPY ./concile /app/concileYou will need to point serve at R2 using the --object-store flag like this:
s3+https://<account-id>.r2.cloudflarestorage.com/<bucket>?region=auto&forcePathStyle=true. You
should expect warm cold-starts to take about 4.5 seconds, or around 7.3 seconds for a true cold
start. Writes will also be a bit slower compared to a local-disk host, simply because every commit
requires a round trip to R2.
We suggest choosing this path only if scaling to zero is more important to your budget than fast write latency, and you are absolutely sure you don't need scheduled tasks! Otherwise, we highly recommend sticking with the Durable-Object-native path we talked about earlier, or exploring Docker self-hosting if you want to leave Cloudflare altogether.
Moving data between hosts
Because the two Cloudflare paths and our non-Cloudflare hosts store data in different ways, your data won't automatically sync between them. If you need to move things around, you can easily export a portable dump from one setup and import it into another:
CONCILE_ADMIN_KEY=… concile migrate export --url https://source.example.com --out dump.json
CONCILE_ADMIN_KEY=… concile migrate import --url https://target.example.com --in dump.jsonPlease remember to deploy your matching schema on the target environment first! The import process will politely refuse to run if your table numbers do not match the ones in your dump file. Keep in mind that imports are meant for fresh deployments rather than merging data, and they only support a single shard right now.
Related
- Deploy and build: Learn more about the
servetarget inconcile deployandconcile build, which give you other great ways to ship your changes. - Scaling: Check this out if you need more power! This guide covers the paid-tier multi-shard Cloudflare router and the Postgres fleet for times when a single node is just not enough.
- Self-hosting with Docker: A fantastic baseline option if you want to skip Cloudflare entirely.
- Vite plugin: Everything you need to know about setting up local development for a Vite app that talks to your deployment.