Provisioning with OpenTofu
VPS Automation
One declarative tofu apply provisions a Hetzner VPS, configures Cloudflare
DNS firewalls, installs Docker runtime, and bootstraps your full-stack
docker-compose environment.
OpenTofu
IaC Engine
Hetzner
Default Host
5 min
Provisioning time
BoringStack’s Deployment path is manual: SSH into a VPS, install Docker, clone the monorepo, compose pull && compose up -d. Some operators prefer this.
The OpenTofu stack is the alternative for people who’d rather drive the same outcome from a single declarative apply. Source lives in infra/bootstrap.
What it does
Section titled “What it does”Starting with a domain on Cloudflare, a Hetzner account, and a filled terraform.tfvars:
Run tofu apply. Minutes later, the site is live at https://<your-domain>: VPS provisioned, DNS configured, HTTPS valid, optional superuser seeded (when superuser_email + superuser_password are set).
What one apply does
Section titled “What one apply does”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
Prerequisites
Section titled “Prerequisites”Domain on Cloudflare
Registered at Cloudflare Registrar, or NS-pointed to Cloudflare.
Hetzner Cloud account
Sign up at Hetzner Cloud, payment method on file.
Hetzner API token
Hetzner Cloud Console, project, Security, API Tokens, 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
The zone overview page in the dashboard, right sidebar.
SSH key
ssh-keygen -t ed25519 if you do not have one; paste the .pub contents.
OpenTofu binary
brew install opentofu on macOS, install
docs elsewhere.
$ git clone https://github.com/boringstack-xyz/boringstack
$ cd boringstack/infra/bootstrap
$ cp terraform.tfvars.example terraform.tfvars
$ tofu init
$ tofu validate
$ tofu apply -auto-approve
ok Initializing provider plugins...
ok Success! The configuration is valid.
ok Apply complete! Resources: 5 added, 0 changed, 0 destroyed.apply itself finishes in a minute or two. The Hetzner server is up, but cloud-init is still bootstrapping the stack in the background.
Wait for cloud-init
Section titled “Wait for cloud-init”Bootstrap (Docker install, monorepo clone, GHCR image pulls, first compose up -d) runs in the background after the server boots. A few minutes on first run.
$ ssh root@$(tofu output -raw vps_ipv4) 'cloud-init status --wait'
ok status: doneVerify
Section titled “Verify”$ curl -sI $(tofu output -raw site_url)/health
ok HTTP/2 200
ok server: cloudflare
ok cf-ray: ...Design choices
Section titled “Design choices”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 the 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 shop. The bootstrap module is provider-agnostic (cloud-init is universal), so replacing the VPS module is the only thing 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.
Opinionated Cloudflare zone defaults
SSL strict, HSTS 6mo, TLS min 1.2, browser integrity on. Matches what
production-labels.yml expects; each setting is one override away.
DNS: apex + 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.
State stays local by default
Single-operator default; an S3 backend block is one paste away for teams.
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.
What stays manual
Section titled “What stays manual”OpenTofu cannot paper over the things providers do not expose APIs for:
Add the domain to Cloudflare
You have to own it: registrar transfer or NS change.
Upgrade Cloudflare to Workers Paid
Billing decision; no API to flip the switch.
Create OAuth apps at Google / GitHub / LinkedIn
No provider APIs for OAuth client registration.
Create Stripe products and prices
Stripe Terraform provider exists but is beta; most teams click through anyway.
Enable Cloudflare Email Service
Beta product; some toggles are not in the Cloudflare provider yet.
Each of these 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 tofu apply again. Cloud-init re-renders compose/.env and restarts the API.
terraform.tfvars shape
Section titled “terraform.tfvars shape”One file with every 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"
# 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, 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, opinionated zone settings, redirect rules.
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 management
Section titled “State management”For a single operator: state file is local, gitignored. Default config.
For a team: point the OpenTofu backend at S3 (or any S3-compatible store: Cloudflare R2, Backblaze B2, Hetzner Object Storage). One block in main.tf:
terraform { backend "s3" { bucket = "boringstack-tofu-state" key = "boringstack/terraform.tfstate" region = "..." }}The state file holds secrets (cloud-init renders with sensitive values). Encrypt at rest; restrict bucket access. Same posture as everywhere else in BoringStack.
Updating
Section titled “Updating”OpenTofu owns the infrastructure. GHCR + the monorepo own the running code.
Code updates land via the 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, applied on the
VPS when you are ready:
ssh root@$(tofu output -raw vps_ipv4)cd /opt/boringstack/infradocker compose pulldocker compose up -dInfra YAML or env-var changes: git pull the monorepo on the VPS, then re-run compose up -d. Infrastructure changes (VPS resize, DNS, firewall rule): edit terraform.tfvars or the modules, then tofu apply.
Scaling up
Section titled “Scaling up”When single-host stops being enough, the upgrade path stays inside OpenTofu without rewrites:
Bigger VPS
Yes: bump vps_type, apply, cloud-init re-runs.
Move Postgres to managed (Neon / Crunchy / RDS)
Yes: drop the Postgres service from compose, add the managed-DB module.
Multiple API replicas behind Hetzner Load Balancer
Yes: adds a modules/loadbalancer/ and parameterizes VPS count.
Multi-region
No: that is when the planned Kubernetes template earns its place.
The progression: 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 / 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”tofu destroyWipes the Hetzner server, removes the Cloudflare records, deletes the firewall and SSH key. Cloudflare zone settings revert to defaults. The state file remains; rm terraform.tfstate* for full cleanup.
Troubleshooting
Section titled “Troubleshooting”apply fails on a Hetzner resource
Hetzner API status plus token scope (must be Read and Write).
apply fails on a Cloudflare resource
Token scope (Zone:DNS:Edit etc.) plus zone ID matches the domain.
apply succeeds but site is unreachable
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
TLS handshake failed; ACME has not issued yet: wait or check Traefik logs.
Unexpected attribute errors in the editor
Stale OpenTofu language-server cache. Run tofu init once and re-open.
Cron backups do not run
rclone config on 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 win.
- 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 itself.
The runtime repos work fine without this one. It’s a convenience layer, not a dependency.
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.