Deploy a containerized web app to Cloudflare
Generic runbook for putting a containerized web app on Cloudflare Workers + the container ("Cloudchamber") wrapper, with D1, R2, KV, Queues, and Durable Objects, then proving it is live. Not tied to any one repo.
Cloudflare is typically a live home, not a throwaway sandbox. Prefer separate staging vs production configs (or env.staging / env.production) and treat a successful deploy as something that stays up.
1. Prerequisites
wranglerCLI (npm i -g wrangleror a project-localnpx wrangler)- Docker (needed to build the container image)
- A Cloudflare account
- Auth:
wrangler login(or a scoped API token in CI)
Confirm you are in the right account before creating anything:
wrangler whoami
Use placeholders only in configs and docs: YOUR_ACCOUNT_ID, <worker-name>, <image>, your-app.workers.dev. Never commit account IDs, API tokens, or real resource UUIDs.
2. Adapter flags and bindings
The app selects Cloudflare adapters with env flags (names vary; these are the usual pattern):
| Flag | Meaning |
|---|---|
CF_STORAGE_BACKEND=d1 |
Relational / SQL store → D1 |
CF_BLOB_BACKEND=r2 |
Object / blob store → R2 |
CF_QUEUES=1 |
Background jobs → Queues |
CF_*_DO |
Coordination / long-lived state → Durable Objects |
wrangler.jsonc (and/or wrangler.containers.jsonc) declares the bindings the Worker and container see at runtime:
- D1 database
- R2 buckets
- KV namespace
- Queues (+ DLQs)
- Durable Object classes + migrations
- Cron triggers
Bindings are how the process reaches those resources. Creating a D1/R2/KV/Queue in the dashboard is not enough — the IDs must land in the wrangler config the deploy uses.
3. Provision resources (once)
List first. Resources may already exist; creating a second copy and pointing config at the empty one is a common outage.
wrangler d1 list
wrangler r2 bucket list
wrangler kv namespace list
wrangler queues list
Create only what is missing:
wrangler d1 create <name>
wrangler r2 bucket create <name>
wrangler kv namespace create <name>
wrangler queues create <name>
wrangler queues create <name>-dlq # if the app uses a DLQ
Put the returned IDs into wrangler.jsonc / wrangler.containers.jsonc (and the matching staging/prod env blocks). Do not invent IDs.
Typical binding shape (placeholders only):
{
"name": "<worker-name>",
"d1_databases": [
{
"binding": "DB",
"database_name": "<d1-name>",
"database_id": "<d1-id>"
}
],
"r2_buckets": [
{ "binding": "BLOBS", "bucket_name": "<r2-bucket>" }
],
"kv_namespaces": [
{ "binding": "KV", "id": "<kv-id>" }
],
"queues": {
"producers": [{ "binding": "QUEUE", "queue": "<queue-name>" }],
"consumers": [
{
"queue": "<queue-name>",
"max_retries": 3,
"dead_letter_queue": "<queue-name>-dlq"
}
]
},
"durable_objects": {
"bindings": [{ "name": "APP_CONTAINER", "class_name": "AppContainer" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["AppContainer"] }
],
"triggers": { "crons": ["0 * * * *"] }
}
Keep staging and production as separate resource sets (or at least separate Worker names + D1/R2/KV/Queues). Do not point staging at prod D1.
4. Why a container, and how to deploy it
A full app bundle (framework server + client assets) often exceeds the Worker 10MB script limit. The usual pattern is to wrap the app in a Cloudflare container — an AppContainer Durable Object — and deploy that via a dedicated config:
- Worker script: thin router / binding surface
- Container image: the actual app
- Config:
wrangler.containers.jsonc
Build the image
Run the project's standalone container build (build-container.sh-style). Set any NEXT_PUBLIC_* (or equivalent public) flags at image-build time so they inline into the client bundle. Runtime env will not rewrite already-bundled client code.
Tag the image immutably. Do not deploy :latest — Cloudflare will not roll a new revision if the tag string did not change.
# example — use the repo's real build script
./build-container.sh
# produces something like: <image>:<git-sha>
Scan configs, then deploy
# GOTCHA — do this before every deploy (see §6)
grep -nE '^\s*,\s*$' wrangler*.jsonc
wrangler deploy --config wrangler.containers.jsonc --autoconfig=false
--autoconfig=false avoids interactive prompts (required in CI).
Deploy output prints the live URL, typically:
https://<worker-name>.<subdomain>.workers.dev
5. Smoke test
Use the URL from deploy output. Do not guess the hostname.
BASE="https://<worker-name>.<subdomain>.workers.dev"
curl -fsS -o /dev/null -w "%{http_code}\n" "$BASE/health" # or the app's health path
curl -fsS -o /dev/null -w "%{http_code}\n" "$BASE/"
curl -fsS -o /dev/null -w "%{http_code}\n" "$BASE/login"
Expect 200 on health, /, and /login (adjust /login if the app has no public login route).
D1 — tables exist remotely:
wrangler d1 execute <d1-name> --remote --command \
"SELECT name FROM sqlite_master WHERE type='table'"
The result should list the app's tables, not an empty schema.
R2 / KV / Queues — confirm bindings resolve:
- Health payload field listing bound services, or
- A single write + read (put an object / KV key, enqueue a no-op and see it land)
If health is 200 but D1 has no tables, the Worker is up and the datastore is the wrong (or never-migrated) database.
6. Gotchas (read before you deploy)
Stray / doubled comma → ValueExpected (blocks the whole deploy)
A lone comma in a wrangler*.jsonc array (often left after deleting the last item) fails parse with ValueExpected and the deploy never starts.
grep -nE '^\s*,\s*$' wrangler*.jsonc
Fix every hit. JSONC allows comments; it does not allow trailing/doubled commas on their own line. Scan all wrangler configs (wrangler.jsonc, wrangler.containers.jsonc, env overlays), not just the one you edited.
CI must be non-interactive
Always pass --autoconfig=false so wrangler does not wait for a TTY prompt.
Immutable image tags
Tag and reference <image>:<git-sha> (or a build id). :latest (or any reused tag) can leave Cloudflare serving the previous container revision.
Prerendered pages 404 in production while next start is green
With OpenNext, generateStaticParams pages are written to the KV incremental
cache, not into .open-next/assets. opennextjs-cloudflare deploy populates
that cache; a bare wrangler deploy does not. The result is every generated
route returning 404 in production while every static page works and the local
production build is perfectly fine.
# before `wrangler deploy`, from the app directory
bunx opennextjs-cloudflare populateCache remote \
-c cloudflare/app-worker/wrangler.jsonc -e "$CF_ENV"
Three things make it stick:
- The
remotesubcommand is required. Without it the CLI rejects the arguments. - Run it from the app directory. It resolves the build relative to the app,
and
nextis not resolvable from a monorepo root. If a build script moves.open-nextelsewhere, stop moving it. - Never set
dynamicParams = falseon those routes. It turns a cache miss into a 404 for a page that exists;notFound()already handles unknown slugs.
Then curl the live URLs in the deploy job — including a generated one — and fail the job on anything that is not 200. This class of bug cannot be caught locally.
Live home, not a lab
Do not tear the Worker / D1 / R2 / KV / Queues down after a green smoke test unless the user explicitly asked to destroy them. Use a staging Worker + resources for experiments; keep production config pointed at prod resources only.
Checklist
wrangler whoami— right account- List existing D1 / R2 / KV / Queues; create only missing ones
- IDs written into the wrangler config the deploy will use
- Adapter env flags set;
NEXT_PUBLIC_*baked into the image - Image tagged immutably
grep -nE '^\s*,\s*$' wrangler*.jsoncis emptywrangler deploy --config wrangler.containers.jsonc --autoconfig=falsepopulateCache remoteran beforewrangler deploy(any SSG route)- curl health +
/+/login+ one generated route → 200 - Remote D1
sqlite_mastershows app tables - R2 / KV / Queues binding check (health field or a write)
Related
Catalog row for the skills README (do not invent a second deploy skill name):
| [deploy-app-cloudflare](deploy-app-cloudflare/SKILL.md) | Cloudflare |