Provisioning with OpenTofu
One declarative tofu apply provisions a Hetzner VPS, configures Cloudflare DNS and a Hetzner firewall scoped to Cloudflare IP ranges, installs Docker runtime, and bootstraps your full-stack docker-compose environment. Minutes later, the site is live at https://<your-domain> with HTTPS valid and optional superuser seeded.
BoringStack’s Deployment path is manual: SSH into a VPS, install Docker, clone the monorepo, then compose pull && compose up -d. Some operators prefer this flow. The OpenTofu stack is the alternative for teams who’d rather drive the same outcome from a single declarative apply. Source lives in infra/bootstrap.
Prerequisites
Section titled “Prerequisites”You’ll need the following before starting:
- Domain on Cloudflare: Registered at Cloudflare Registrar, or nameserver-pointed to Cloudflare.
- Hetzner Cloud account: Sign up at Hetzner Cloud. Payment method on file.
- Hetzner API token: Hetzner Cloud Console, your project, Security, API Tokens. Scope: Read and Write.
- Cloudflare API token: Cloudflare, My Profile, API Tokens, Custom Token. Scope:
Zone:DNS:Edit,Zone:Zone Settings:Edit,Zone:Rulesets:Editon the target zone. - Cloudflare zone ID: Zone overview page in the dashboard, right sidebar.
- SSH key: Run
ssh-keygen -t ed25519if you don’t have one. You’ll paste the.pubcontents. - OpenTofu binary:
brew install opentofuon macOS — version 1.12 or newer (the stack uses variable-drivenprevent_destroyand native state locking). See install docs for other OSes.
Four commands to provision:
git clone https://github.com/boringstack-xyz/boringstackcd boringstack/infra/bootstrapcp terraform.tfvars.example terraform.tfvars# Edit terraform.tfvars: fill in your domain, tokens, SSH key, VPS size, and stack secretsThen apply:
tofu inittofu validatetofu apply -auto-approveApply itself finishes in a minute or two. The Hetzner server is up, but cloud-init is still bootstrapping the stack in the background. Watch the bootstrap status:
ssh root@$(tofu output -raw vps_ipv4) 'cloud-init status --wait'When it returns status: done, test connectivity:
curl -sI $(tofu output -raw site_url)/healthExpect HTTP/2 200 from Cloudflare.
How it works
Section titled “How it works”flowchart LR tfvars["terraform.tfvars<br/>tokens · domain · sizing"] apply["tofu apply"] hetz["Hetzner<br/>VPS · SSH key · firewall"] cf["Cloudflare<br/>A records · zone settings"] init["cloud-init on first boot<br/>installs Docker + git<br/>runs bootstrap.sh"] script["bootstrap.sh from repo<br/>clones monorepo<br/>renders compose/.env<br/>compose pull && compose up -d"] live["live site"] tfvars --> apply apply --> hetz apply --> cf hetz --> init --> script --> live cf -.->|DNS resolves| live
What stays manual
Section titled “What stays manual”OpenTofu cannot automate what cloud providers do not expose APIs for. These steps require manual setup:
- Add the domain to Cloudflare: you have to own it via registrar transfer or nameserver change.
- Upgrade Cloudflare to Workers Paid: a billing decision, no API to flip it.
- Create OAuth apps at Google, GitHub, or LinkedIn: no provider APIs for OAuth client registration.
- Create Stripe products and prices: a Terraform provider exists but is beta; most teams click through the UI.
- Enable Cloudflare Email Service: still beta; some toggles are not in the Cloudflare provider.
Each is one-time per project and documented in its own runbook (for example Cloudflare Email setup). Once you have the credentials, paste them into terraform.tfvars and run tofu apply again. Cloud-init re-renders compose/.env and restarts the API.
terraform.tfvars shape
Section titled “terraform.tfvars shape”One file holds every configuration knob:
# Requiredhetzner_api_token = "..."cloudflare_api_token = "..."cloudflare_zone_id = "..."domain = "boringstack.example"
# VPS sizing, Hetzner-native namesvps_type = "cx32" # 4 vCPU / 8 GBvps_location = "fsn1"
# Protection (default true). Blocks tofu *and* Hetzner from destroying the# VPS; set false only for a deliberate rebuild or teardown.prevent_server_destroy = true
# Stack secretsjwt_secret = "..." # 32+ charspostgres_password = "..."valkey_password = "..."acme_email = "ops@example.com"
# Optional integrations, leave empty to skipemail_provider = "cloudflare"cloudflare_email_api_token = ""google_oauth_client_id = ""stripe_secret_key = ""# ... etcEverything in terraform.tfvars.example ships with comments explaining what it’s for and which features it enables.
Repo layout
Section titled “Repo layout”- main.tf: top-level composition. Wires modules to variables and declares outputs.
- variables.tf: input variable declarations with type and description.
- outputs.tf: VPS IP, DNS records, ready-to-paste SSH command, site URL.
- terraform.tfvars.example: all knobs with comments. Copy to
terraform.tfvarsand fill in. - modules/hetzner/: VPS, SSH key, firewall, cloud-init injection.
- modules/cloudflare/: DNS records, sane zone setting defaults, redirect rules, and edge security/performance — a bot/scanner WAF block rule, an auth rate-limit rule, an asset cache rule, and DNSSEC.
- modules/bootstrap/: cloud-init template that installs Docker plus git, then runs
bootstrap.sh. - bootstrap.sh: versioned shell script. Clones the monorepo, renders
compose/.env, runscompose pull && compose up -d.
State management
Section titled “State management”State defaults to a local terraform.tfstate — gitignored, and fine for a single operator spinning something up quickly. But local state is a single point of failure: lose the file and you can no longer plan, reconcile, or safely destroy the stack — more so now that the VPS is delete-protected (see Destroying).
For anything long-lived, move state to Cloudflare R2 (S3-compatible, no egress fees, an account you already have for DNS). main.tf ships a ready-to-uncomment backend "s3" block pre-filled with the R2-specific flags (use_path_style, the skip_* preflight toggles, skip_s3_checksum) and use_lockfile = true for native locking — no DynamoDB table. Deployment-specific values live in a gitignored backend.hcl, copied from backend.hcl.example:
bucket = "boringstack-tfstate"key = "bootstrap/terraform.tfstate"endpoints = { s3 = "https://<CLOUDFLARE_ACCOUNT_ID>.r2.cloudflarestorage.com"}Create an R2 bucket and an R2 API token (Object Read & Write), then migrate the existing local state:
cp backend.hcl.example backend.hcl # fill in bucket + account idexport AWS_ACCESS_KEY_ID=<r2 access key id>export AWS_SECRET_ACCESS_KEY=<r2 secret access key># uncomment the backend "s3" block in main.tf, then:tofu init -backend-config=backend.hcl -migrate-stateCredentials stay in the AWS_* env vars, never on disk. Hetzner Object Storage, Backblaze B2, or AWS S3 work the same way — only endpoints.s3 (and the skip_*/use_path_style flags, for true AWS) differ.
The state file contains secrets (cloud-init renders with sensitive values). Encrypt at rest and restrict bucket access. Same security posture as everywhere else in BoringStack.
Updating
Section titled “Updating”OpenTofu owns the infrastructure. GHCR and the monorepo own the running code.
Code updates land via release workflows. Push to main on apps/api or apps/ui, a new image tag appears on GHCR, and WUD on the VPS auto-deploys app containers.
Base-image updates remain manual. Apply them on the VPS when ready:
ssh root@$(tofu output -raw vps_ipv4)cd /opt/boringstack/infradocker compose pulldocker compose up -dFor infra YAML or env-var changes: git pull the monorepo on the VPS, then re-run compose up -d. For infrastructure changes (VPS resize, DNS, firewall rule): edit terraform.tfvars or the modules, then run tofu apply.
Scaling up
Section titled “Scaling up”When single-host stops working, the upgrade path stays inside OpenTofu without rewrites:
- Bigger VPS: bump
vps_type, apply. Cloud-init re-runs. - Move Postgres to managed (Neon, Crunchy, RDS): drop the Postgres service from compose, add the managed-DB module.
- Multiple API replicas behind Hetzner Load Balancer: adds
modules/loadbalancer/and parameterizes VPS count. - Multi-region: that’s when the planned Kubernetes template earns its place.
The progression is vertical, managed data, horizontal stateless, cluster. Each step is additive, not a rewrite.
Swapping the cloud provider
Section titled “Swapping the cloud provider”The bootstrap module talks to cloud-init, which every major cloud accepts. Swapping Hetzner for DigitalOcean, OVH, or Linode means replacing module "vps" in main.tf with the matching module. The rest of the graph (Cloudflare, bootstrap, outputs) doesn’t change. Per-provider modules ship as they prove themselves.
Destroying
Section titled “Destroying”The VPS is protected by two independent layers (see Design choices): OpenTofu’s prevent_destroy and Hetzner’s API-level delete_protection/rebuild_protection. Both are driven by the prevent_server_destroy variable, which defaults to true. So a plain tofu destroy is refused — by design, you can’t nuke production in a single command.
To deliberately tear down, lower the gate first, then destroy:
# Set prevent_server_destroy = false in terraform.tfvars (or pass -var on each command).tofu apply # lifts the Hetzner delete/rebuild locks in placetofu destroy # now succeedsThe apply step is required: Hetzner won’t honour a delete while the API lock is still set, so the lock must be lifted by an apply before destroy can remove the server. This wipes the Hetzner server, removes Cloudflare records, deletes the firewall and SSH key, and reverts Cloudflare zone settings to defaults. With remote state the state object stays in the bucket; with local state, run rm terraform.tfstate* for full cleanup.
Troubleshooting
Section titled “Troubleshooting”- apply fails on a Hetzner resource: check Hetzner API status and token scope (must be Read and Write).
- apply fails on a Cloudflare resource: verify token scope (
Zone:DNS:Edit, etc.) and that zone ID matches the domain. - apply succeeds but site is unreachable: run
ssh ... 'cloud-init status'. Bootstrap may still be running. - Site returns 522 from Cloudflare: origin not responding. Check
docker compose logs traefik apion the server. - Site returns 525 from Cloudflare on first apply: expected for the first 2-5 minutes. Traefik needs DNS to propagate globally before Let’s Encrypt can complete the HTTP-01 challenge. Until the cert lands, Cloudflare can’t validate the origin and serves 525. Traefik retries on its own. Confirm with
docker compose logs traefik | grep -i acme. If it’s still failing past 10 minutes, check that the apex A/AAAA records resolve publicly:dig +short @1.1.1.1 <domain>. - Unexpected attribute errors in the editor: stale OpenTofu language-server cache. Run
tofu initonce and re-open. - Cron backups do not run: run
rclone configon the server. The cron entry references a remote that must be configured.
When to skip OpenTofu
Section titled “When to skip OpenTofu”- You like the SSH-and-edit flow and don’t see the benefit.
- You’re already on a different IaC tool (Pulumi, AWS CDK, Crossplane).
- You’re deploying to a managed platform (Vercel, Render, Fly) that handles provisioning.
The runtime repos work fine without this one. It’s a convenience layer, not a dependency.
Design choices
Section titled “Design choices”- OpenTofu, not Terraform: Terraform is BUSL-licensed. OpenTofu is the MPL-licensed fork, drop-in compatible. Same
.tffiles work in both. - Cloud-init for entry point, bootstrap.sh for the work: cloud-init installs Docker plus git and runs a single script committed in the repo. Stack-specific logic lives in readable bash: debuggable, testable, versioned.
- Hetzner module first, others swappable: Hetzner is the cheapest production-viable VPS. The bootstrap module is provider-agnostic (cloud-init is universal), so replacing the VPS module is all that changes for DigitalOcean, OVH, or Linode.
- Single vps_type variable using provider-native size names:
cx32,s-2vcpu-4gb. The same names the provider docs, support, and billing page use. - Sane Cloudflare zone defaults: SSL strict, HSTS 6 months, TLS min 1.2, browser integrity on. Matches what
production-labels.ymlexpects. Each setting is one override away. - DNS: apex and www only: one A/AAAA pair on the apex serves both the SPA and
/api/*via same-origin path routing.www.is a CNAME to apex with a redirect rule. Noapi.subdomain. Traefik path-routes/api/*on the same host. - Edge bot-blocking, on by default: a single Cloudflare WAF custom rule (
http_request_firewall_custom) blocks common scanner probes —/.env,/.git/,/wp-admin,/xmlrpc, … — at the edge, so they never reach Traefik or the API. Configurable viabot_block_paths; disable withenable_bot_blocking = false. Works on the Cloudflare Free plan. - State stays local by default, R2 one step away: single-operator default is a local state file.
main.tfships a pre-wired, commented Cloudflare R2 backend (S3-compatible, native locking, no DynamoDB); enabling it iscp backend.hcl.example backend.hcl, uncomment the block, andtofu init -migrate-state. See State management. - Production is hard to nuke, on purpose: the VPS carries OpenTofu
prevent_destroyand Hetzner API-leveldelete_protection/rebuild_protection, both gated behind a singleprevent_server_destroyvariable (default on). Defense in depth — destroying or replacing the box (and the Postgres, ACME, and GlitchTip volumes it holds) takes a deliberate, explicit gate-lowering, not a straytofu destroyor a misclick in the Hetzner console. See Destroying. - Secrets in terraform.tfvars (gitignored): same pragmatic floor as
compose/.env. Upgrade to a secret manager when team size demands it. - Outputs print, never side-effect: apply prints the IP, ssh command, and site URL. Never auto-opens anything.
- Optional bootstrap repo, separate from infra-compose: same logic as the planned Kubernetes template. Separation lets operators skip the tool entirely.
Related
Section titled “Related”- Deployment: the manual path this automates.
- Firewall & TLS: handled by the Hetzner module’s firewall rules.
- Backups: cron plus rclone, baked into
bootstrap.sh. - Env backup and secrets: password-manager backup for
compose/.envafter provisioning. - OAuth provider setup: Google, GitHub, LinkedIn console walkthroughs.
- Cloudflare Email setup: the bit that stays manual after
apply.