Forge's cloud infrastructure lives under infra/ as
OpenTofu (Apache-2.0/MPL — the open-source successor to Terraform after
its BSL relicense). The HCL is byte-for-byte Terraform-compatible.
HONEST CEILING — read this first. Everything under
infra/is written,fmt-clean,validate-clean code, plus this runbook. It has NOT beentofu apply'd anywhere. There are no cloud accounts or paid infrastructure attached to this repo, so no state exists and no cloud resources have been created. This document is the path a human, with real cloud accounts, follows from this blueprint to a live deployment — treat it as reviewed and ready-to-run, not as evidence anything is running.
For the day-1 single-machine path (no cloud accounts, no OpenTofu), see quickstart.md instead — this document is for standing up the same stack on managed cloud infrastructure (Hetzner Cloud + Cloudflare + Fly.io) rather than a single Docker host you administer by hand.
| Concern | Platform | Module |
|---|---|---|
| Control plane (servers, private network, firewall, volumes) | Hetzner Cloud | infra/modules/hetzner-control-plane |
| Object storage (R2), DNS, Zero Trust Tunnel | Cloudflare | infra/modules/cloudflare |
| Agent runtime (scale-to-zero task execution) | Fly.io | infra/modules/fly-agents (fly.toml + flyctl, not a Tofu resource — see that module's README for why) |
| Remote state | Cloudflare R2 | s3 backend, per-env state key (see below) |
Three environment compositions call these modules with different sizing:
| Environment | Sizing | Composition |
|---|---|---|
dev |
Minimal single node (cpx21, 20 GB volumes, delete protection off) |
infra/envs/dev |
staging |
Mid (cpx41, 50/100 GB volumes, delete protection on) |
infra/envs/staging |
prod |
HA-leaning (cpx51 x2, 100/200 GB volumes, floating IP, delete protection enforced) |
infra/envs/prod |
Each infra/envs/<env>/README.md documents that environment's exact sizing
defaults and the honest caveats (prod's second node is reserved capacity,
not an active standby — see infra/envs/prod/README.md).
Install locally:
- OpenTofu (
tofu,>= 1.6) — used forinit/plan/apply. Install from opentofu.org. terraform(any recent 1.x) is also fine forfmt/validateonly — the HCL is identical; this repo's local gate uses whichever is onPATH.flyctl—curl -L https://fly.io/install.sh | sh— for the agent runtime deploy step.
Accounts + tokens (none of these exist for this repo — you provision them):
- A Hetzner Cloud project + API token (Console → Security → API Tokens, read+write scope).
- A Cloudflare account with the target DNS zone, an R2 subscription enabled, and a scoped API token (Account → R2 Edit, Zone → DNS Edit, Account → Cloudflare Tunnel Edit for the target zone/account).
- A Fly.io account +
FLY_API_TOKEN(flyctl auth token) — seeinfra/modules/fly-agents/README.md. - An SSH keypair to register on the Hetzner servers.
The Tofu remote-state bucket is shared across every environment (only
the state object key differs per env). Create it exactly once, out of
band of any per-env apply — the cloudflare module's
create_state_bucket variable exists for this and defaults to false
everywhere in infra/envs/* on purpose, so a normal env apply never races
to (re)create it.
The simplest bootstrap is the Cloudflare dashboard (R2 → Create bucket,
name it forge-tfstate) plus an R2 API token (R2 → Manage API Tokens).
Alternatively, run a one-off Tofu apply with create_state_bucket = true
against the cloudflare module directly and never repeat it.
Record the account id, bucket name, and R2 access key pair — you'll need them for every subsequent step.
cp infra/backend.hcl.example infra/backend.hcl # gitignored — fill in bucket/endpoint
export AWS_ACCESS_KEY_ID="..." # R2 access key id (state backend)
export AWS_SECRET_ACCESS_KEY="..." # R2 secret access key (state backend)
export TF_VAR_hcloud_token="..." # Hetzner Cloud API token
export TF_VAR_cloudflare_api_token="..." # Cloudflare scoped API tokeninfra/backend.hcl and every TF_VAR_* value are secrets or
account-specific — never commit them. infra/.gitignore enforces this
for backend.hcl and any *.tfvars that isn't a *.example template.
Each environment has its own placeholder template:
cp infra/envs/dev/terraform.tfvars.example infra/envs/dev/dev.auto.tfvars
cp infra/envs/staging/terraform.tfvars.example infra/envs/staging/staging.auto.tfvars
cp infra/envs/prod/terraform.tfvars.example infra/envs/prod/prod.auto.tfvarsEdit each <env>.auto.tfvars with your domain, Cloudflare account/zone ids,
SSH public key(s), and admin CIDRs. These files are gitignored — only the
.example templates are tracked. Secrets never go in these files —
see step 2.
Every environment is its own Tofu root (it has its own provider config and
backend). Run the full cycle from inside infra/envs/<env>/:
cd infra/envs/dev # or staging, or prod
tofu init \
-backend-config=../../backend.hcl \
-backend-config="key=forge/dev/terraform.tfstate"
tofu fmt -check -recursive ../.. # repo-wide formatting gate
tofu validate # offline — no cloud calls
tofu plan -out tfplan
# review the plan carefully, especially for prod
tofu apply tfplanRepeat for staging (key=forge/staging/terraform.tfstate) and prod
(key=forge/prod/terraform.tfstate) — swap the directory and the
-backend-config="key=..." value; nothing else changes.
What this repo has actually run: tofu fmt -check and tofu validate
(via terraform, identical HCL) — offline, no credentials, no cloud calls.
plan/apply require the real accounts and tokens above and have not been
exercised here.
The Fly agent runtime is modeled as fly.toml templates + flyctl, not a
Tofu resource (see
infra/modules/fly-agents/README.md
for why). Once FLY_API_TOKEN is exported:
make -C infra/modules/fly-agents deploy ENV=dev # or staging / prodThis creates the Fly app (idempotent-best-effort), deploys
fly.<env>.toml, and applies the min/max machine scaling for that
environment. See that module's README for the scale / regions /
status / render targets.
Per environment, from infra/envs/<env>/:
tofu plan -destroy -out tfplan.destroy
tofu apply tfplan.destroyNotes:
stagingandprodhaveenable_delete_protection = trueby default (prod hard-enforces it via a variable validation) — Hetzner will refuse to delete protected servers/volumes. Setenable_delete_protection = falsein the relevant*.auto.tfvars(or, for prod, temporarily comment out the enforcing validation block) and re-applybefore a destroy is possible. This is deliberate friction — don't remove it casually.- Destroying an environment does not delete the shared R2 state
bucket (
create_state_bucketstaysfalsein every env) or the other environments' state objects. To retire the bucket itself, do so out-of-band, after every environment's state has been destroyed and migrated/backed up as needed. - Fly apps are torn down separately:
flyctl apps destroy <app-name>(seeinfra/modules/fly-agents/deploy.sh).
This token cannot modify .github/workflows/, so CI for infra/ is
documented here as a follow-up, not committed. A future workflow should,
on PRs touching infra/**, run:
tofu fmt -check -recursive infratflint --recursive(hcloud + cloudflare rulesets)tofu validateper environment (-backend=false, no credentials needed)tofu planper environment on protected branches, using read-only credentials, with the plan posted as a PR comment (never auto-applied from CI)
Wire TF_VAR_hcloud_token / TF_VAR_cloudflare_api_token /
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY through the CI provider's
secret store — the same four values used locally in step 2.
The commands this repo's make infra-validate target runs (see
Makefile):
terraform fmt -check -recursive infra
for d in infra infra/envs/dev infra/envs/staging infra/envs/prod; do
(cd "$d" && terraform init -backend=false -input=false >/dev/null && terraform validate)
doneAll of the above run offline: no Hetzner/Cloudflare/Fly credentials, no network calls beyond the local provider plugin cache.