concile
Deploy & Operate

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)
Commandconcile deploy --target cloudflarehand-wired, no concile deploy integration
StorageDO-SQLite (per-object)R2 (object-store substrate)
Scheduled functions / crons / triggersFire: the DO's own alarm wakes themDo not fire: container stops ~5s after idle
StatusSupported targetExperimental

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 wrangler

Authenticate 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

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 production

When you run concile deploy --target cloudflare, here is what happens behind the scenes:

  • It double checks your wrangler.jsonc file. If durable_objects.bindings, migrations, or compatibility_flags are 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 deploy for you. If you have wranglerEnv set in your configuration under environments.<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:

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_KEY

Make 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

What works

Reactive queries/mutations, WebSocket syncWe 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, triggersThese work wonderfully. The DO has its own built-in alarm that will wake up your timers even if it is idle or hibernating.
File storageFully supported using the R2 binding we mentioned above.
ScaleFor 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.

concile/schema.ts
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

.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: 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/concile

You 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.json

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

  • Deploy and build: Learn more about the serve target in concile deploy and concile 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.

On this page