Skip to content
BoringStack
Star

Provisioning with OpenTofu

11 min read

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.

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:Edit on the target zone.
  • Cloudflare zone ID: Zone overview page in the dashboard, right sidebar.
  • SSH key: Run ssh-keygen -t ed25519 if you don’t have one. You’ll paste the .pub contents.
  • OpenTofu binary: brew install opentofu on macOS — version 1.12 or newer (the stack uses variable-driven prevent_destroy and native state locking). See install docs for other OSes.

Four commands to provision:

Terminal window
git clone https://github.com/boringstack-xyz/boringstack
cd boringstack/infra/bootstrap
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars: fill in your domain, tokens, SSH key, VPS size, and stack secrets

Then apply:

Terminal window
tofu init
tofu validate
tofu apply -auto-approve

Apply 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:

Terminal window
ssh root@$(tofu output -raw vps_ipv4) 'cloud-init status --wait'

When it returns status: done, test connectivity:

Terminal window
curl -sI $(tofu output -raw site_url)/health

Expect HTTP/2 200 from Cloudflare.

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

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.

One file holds every configuration knob:

# Required
hetzner_api_token = "..."
cloudflare_api_token = "..."
cloudflare_zone_id = "..."
domain = "boringstack.example"
# VPS sizing, Hetzner-native names
vps_type = "cx32" # 4 vCPU / 8 GB
vps_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 secrets
jwt_secret = "..." # 32+ chars
postgres_password = "..."
valkey_password = "..."
acme_email = "ops@example.com"
# Optional integrations, leave empty to skip
email_provider = "cloudflare"
cloudflare_email_api_token = ""
google_oauth_client_id = ""
stripe_secret_key = ""
# ... etc

Everything in terraform.tfvars.example ships with comments explaining what it’s for and which features it enables.

  • 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.tfvars and 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, runs compose pull && compose up -d.

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:

backend.hcl
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:

Terminal window
cp backend.hcl.example backend.hcl # fill in bucket + account id
export 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-state

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

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:

Terminal window
ssh root@$(tofu output -raw vps_ipv4)
cd /opt/boringstack/infra
docker compose pull
docker compose up -d

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

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.

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.

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:

Terminal window
# Set prevent_server_destroy = false in terraform.tfvars (or pass -var on each command).
tofu apply # lifts the Hetzner delete/rebuild locks in place
tofu destroy # now succeeds

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

  • 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 api on 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 init once and re-open.
  • Cron backups do not run: run rclone config on the server. The cron entry references a remote that must be configured.
  • 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.

  • OpenTofu, not Terraform: Terraform is BUSL-licensed. OpenTofu is the MPL-licensed fork, drop-in compatible. Same .tf files 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.yml expects. 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. No api. 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 via bot_block_paths; disable with enable_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.tf ships a pre-wired, commented Cloudflare R2 backend (S3-compatible, native locking, no DynamoDB); enabling it is cp backend.hcl.example backend.hcl, uncomment the block, and tofu init -migrate-state. See State management.
  • Production is hard to nuke, on purpose: the VPS carries OpenTofu prevent_destroy and Hetzner API-level delete_protection/rebuild_protection, both gated behind a single prevent_server_destroy variable (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 stray tofu destroy or 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.