Deployment

Infrastructure

The long-lived Cloudflare resources NuxtStart declares in OpenTofu, and how to create them.

Everything a deployment is replaced around is declared in infra/ with OpenTofu, so the repository says what exists in your Cloudflare account instead of the dashboard holding that knowledge alone.

What it declares

Three resources per Runtime Context, named <slug>-<app>-<context>:

ResourceForHolds
R2 bucketapps/webuploaded files
Hyperdriveapps/webthe pooled connection to Postgres
D1apps/sitethe @nuxt/content dump

Each is declared once in a provider module under infra/modules/, and one root capability file composes it — a file per row of that table, not per application, so apps/web's two rows are two files. A committed infra/contexts/<context>.tfvars file activates a Runtime Context; the root derives an independent backend key and OpenTofu data directory for it.

What it does not declare

One resource, one owner.

OwnerOwns
OpenTofuthe three resources above
varlock-wrangler deploythe Worker script, its versions, and every binding on it
.github/workflows/deploy.ymlwhich event deploys which Runtime Context
Your secret storecredentials
Committed .env.<context>non-secret per-context values, including names and ids

Worker scripts are absent on purpose. A Worker's code changes on every push and its bindings are generated from nuxt.config.ts at build time, so declaring it here would give it two writers. The Postgres instance behind Hyperdrive is absent too — Cloudflare does not run it, and its connection string reaches OpenTofu from your secret store.

Running it

infra/tofu.sh <context> <command>

The context is the first argument and has no default. The script fetches every credential into one process, so nothing lands in your shell. Per context, the order is: init, plan, read it, apply, commit the ids from output into that context's .env.<context>, then deploy.

Never export a Cloudflare or R2 credential into a shell, never write one into a .tfvars, and never commit a *.tfstate. The state holds a database password.
CI plans every active Runtime Context and never applies. Production stays out of the matrix while only infra/contexts/production.tfvars.example exists; committing it as production.tfvars activates it. A machine identity is allowed only init, validate, plan, output, show, providers and version — every write subcommand is refused by name.

Nothing discovers these resources at runtime, and an apply produces two kinds of output. The names your slug decides — the bucket and the D1 database — are printed so a plan says what it created, and go nowhere: each application derives them. The ids Cloudflare mints are the ones no rule can know in advance, so paste those into that context's value file in the same commit as the apply, or the next build uses the old ones. Project identity covers which names derive.

Going further

infra/README.md is the runbook: how to run it, how to add a Runtime Context, and what to do when a command refuses. First deploy is the one-time setup for the non-production contexts, and Going to production activates the last one.

Copyright © 2026