A pragmatic Terraform stack for solo founders
What to put in Terraform, what to leave in the dashboard, and how to keep the diff small enough to actually read. The exact stack I use across Vercel, Supabase, Cloudflare, and DigitalOcean for sarmalinux.com, available at github.com/sarmakska/terraform-stack.
Why IaC matters solo
The dashboards are great until they aren't. Vercel, Supabase, and Cloudflare all ship lovely admin UIs that let you click an env var into place, add a DNS record, flip a redirect URL. Six months in, you have eighty settings spread across four products and zero memory of which one you changed when the password reset email started landing in spam. There is no audit log you can read in under a minute, and the "who pushed the last setting" answer is always "me, probably, in October".
Terraform fixes exactly that problem and nothing else. It is not a deployment tool. It is not a secrets manager. It is a diffable, version controlled record of every load-bearing piece of cloud config you own, so that on a Tuesday morning six months from now you can git blame a DNS record and learn that yes, you did set the TTL to 60, and yes, you did it deliberately.
The trap solo founders fall into is the other direction: putting everything in Terraform, then drowning in 4000 line plans that nobody reads. The whole point of this playbook is the small version. Codify what is load-bearing, leave the rest in the console, and keep the plan output to fewer than twenty lines on a normal change.
Terraform is not a deployment tool. It is a memory aid for the version of you who, in eight months, will be staring at a DNS record wondering who set the TTL to 60.
What to leave out
The rules I use, written on the inside of my eyelids by now:
Secret values never go in Terraform. The reference to a secret (an env var named RESEND_API_KEY) goes in; the value lives in Vercel's encrypted store, or in .env.local on my machine, or in a 1Password vault. Terraform sees the shape, not the contents. Anything else and the state file becomes a leak waiting to happen.
A/B-tested feature flags do not go in Terraform. Those change five times a week from the marketing dashboard. If you Terraform them, you spend your life rebasing module changes around copy tweaks. Use a feature flag service (PostHog, Statsig, a row in Postgres) and let it own that surface.
Frequently changing copy and content stays in the database or CMS. Blog posts, landing page hero text, pricing tiers that move quarterly. Terraform belongs to the things that, if changed wrongly, would page you at 2am.
What is left? Projects, domains, DNS, env var names, storage buckets, the VPS, the firewall, the database project itself, redirect URLs, webhook endpoints. The skeleton of the stack. That's the right size.
Repo layout
One repo, two environments, four modules. The shape is boring on purpose; boring is what you want at 2am.
textterraform-stack/ ├── README.md ├── envs/ │ ├── prod/ │ │ ├── main.tf │ │ ├── versions.tf │ │ ├── backend.tf │ │ └── terraform.tfvars │ └── staging/ │ ├── main.tf │ ├── versions.tf │ └── backend.tf └── modules/ ├── vercel/ │ ├── main.tf │ ├── variables.tf │ └── outputs.tf ├── supabase/ ├── cloudflare/ └── do/
versions.tf pins every provider. Drift from "latest" providers is the single most common cause of a Friday afternoon emergency I have seen, so the pins live in source control and only move on purpose.
hcl# envs/prod/versions.tf terraform { required_version = ">= 1.9.0" required_providers { vercel = { source = "vercel/vercel" version = "~> 2.5" } supabase = { source = "supabase/supabase" version = "~> 1.3" } cloudflare = { source = "cloudflare/cloudflare" version = "~> 4.40" } digitalocean = { source = "digitalocean/digitalocean" version = "~> 2.43" } } }
State backend choice
Local state is fine for a weekend project and a disaster for anything you charge money for. Two reasonable options, both free for one person.
Terraform Cloud free tier gives you hosted state, locking, run history, and up to five users at no cost. The setup is two lines in backend.tf and a one time terraform login. The state file is encrypted at rest, history is searchable, and if you ever bring on a contractor you add them in the UI. This is what I use, and I would recommend it to anyone who has not got strong reasons otherwise.
hcl# envs/prod/backend.tf terraform { cloud { organization = "sarmalinux" workspaces { name = "prod" } } }
Cloudflare R2 with a homemade lock is the cheap-and-purist option. R2 has no egress fees and a generous free tier; the catch is that Terraform's S3 backend expects DynamoDB for locking, which R2 does not provide. You can fake it with a tiny Cloudflare Worker that issues a lock via Workers KV, but you are now maintaining a Worker for the privilege of saving £0/month versus the free Terraform Cloud tier. The juice is not worth the squeeze unless you have a strict "no third-party state hosting" policy.
Vercel module
The Vercel module owns: the project, its production domain, environment variable references, and a deploy hook that the marketing team (read: future me) can fire from a button.
hcl# modules/vercel/main.tf resource "vercel_project" "this" { name = var.name framework = "nextjs" git_repository = { type = "github" repo = var.github_repo } serverless_function_region = "lhr1" } resource "vercel_project_domain" "apex" { project_id = vercel_project.this.id domain = var.apex_domain } resource "vercel_project_domain" "www" { project_id = vercel_project.this.id domain = "www.${var.apex_domain}" redirect = var.apex_domain redirect_status_code = 308 } resource "vercel_project_environment_variable" "env" { for_each = var.env_vars project_id = vercel_project.this.id key = each.key value = each.value target = ["production"] sensitive = true } resource "vercel_deploy_hook" "rebuild" { project_id = vercel_project.this.id name = "manual-rebuild" ref = "main" } output "deploy_hook_url" { value = vercel_deploy_hook.rebuild.url sensitive = true }
Values for env_vars come from terraform.tfvars, which is gitignored. Marking the output sensitive = true stops the URL leaking into plan logs that get posted on PRs. The first time you forget that, you rotate a webhook URL on a Sunday evening; you only forget it once.
Supabase module
The official Supabase provider manages the project, auth settings, and redirect URLs. Database migrations stay out. Terraform is a terrible migration runner; it has no notion of order beyond DAG dependencies and no rollback. Use supabase db push or the Supabase CLI for schema; let Terraform own everything around the database.
hcl# modules/supabase/main.tf resource "supabase_project" "this" { organization_id = var.org_id name = var.name database_password = var.db_password region = "eu-west-2" } resource "supabase_settings" "auth" { project_ref = supabase_project.this.id auth = jsonencode({ site_url = "https://${var.apex_domain}" uri_allow_list = "https://${var.apex_domain}/**,http://localhost:3000/**" jwt_exp = 3600 mailer_otp_exp = 3600 mailer_secure_email_change_enabled = true external_email_enabled = true external_anonymous_users_enabled = false }) }
db_password is sensitive, set via the CLI as TF_VAR_db_password at apply time and never written to a file. Project rotation is a one line variable change; the rest of the state catches up on the next apply.
Cloudflare module
Cloudflare is the most pleasant Terraform target I have used. The schema is stable, the docs match reality, and the diff output reads like English. This is where the bulk of small daily changes live: a new DNS record, a TXT record for a SaaS verification, an R2 bucket for blog images.
hcl# modules/cloudflare/main.tf resource "cloudflare_record" "apex_a" { zone_id = var.zone_id name = "@" type = "A" value = "76.76.21.21" # Vercel anycast ttl = 1 proxied = false } resource "cloudflare_record" "www" { zone_id = var.zone_id name = "www" type = "CNAME" value = "cname.vercel-dns.com" ttl = 1 proxied = false } resource "cloudflare_record" "mx" { for_each = toset(["mx00.ionos.co.uk", "mx01.ionos.co.uk"]) zone_id = var.zone_id name = "@" type = "MX" value = each.value priority = 10 ttl = 3600 } resource "cloudflare_record" "spf" { zone_id = var.zone_id name = "@" type = "TXT" value = "v=spf1 include:_spf.perfora.net include:_spf.kundenserver.de ~all" ttl = 3600 } resource "cloudflare_r2_bucket" "blog_images" { account_id = var.account_id name = "sarmalinux-blog-images" location = "weur" }
Every DNS record I own lives in this file. The day someone asks "do we have a DMARC record?" the answer is grep DMARC modules/cloudflare/main.tf, not a forty minute click-through of the Cloudflare dashboard.
DigitalOcean droplet
The VPS that hosts auxiliary services (Whisper, OpenTTS, an n8n instance) lives in DigitalOcean. The module owns the droplet itself, the firewall, a project tag for billing, and optionally a managed Postgres if you want one. cloud-init userdata is baked into the resource so a fresh apply produces a fully provisioned box.
hcl# modules/do/main.tf resource "digitalocean_project" "studio" { name = "sarmalinux-studio" description = "Auxiliary infra for sarmalinux.com" purpose = "Web Application" environment = "Production" resources = [digitalocean_droplet.app.urn] } resource "digitalocean_droplet" "app" { image = "ubuntu-24-04-x64" name = "studio-app-01" region = "lon1" size = "s-2vcpu-4gb" ssh_keys = var.ssh_key_ids user_data = <<-EOT #cloud-config package_update: true package_upgrade: true packages: - ufw - docker.io - docker-compose-v2 - fail2ban users: - name: deploy groups: [sudo, docker] shell: /bin/bash ssh_authorized_keys: - ${var.deploy_pubkey} runcmd: - ufw default deny incoming - ufw default allow outgoing - ufw allow 22/tcp - ufw allow 80/tcp - ufw allow 443/tcp - ufw --force enable - systemctl enable --now docker EOT } resource "digitalocean_firewall" "app" { name = "studio-app-fw" droplet_ids = [digitalocean_droplet.app.id] inbound_rule { protocol = "tcp" port_range = "22" source_addresses = var.admin_cidrs } inbound_rule { protocol = "tcp" port_range = "80" source_addresses = ["0.0.0.0/0", "::/0"] } inbound_rule { protocol = "tcp" port_range = "443" source_addresses = ["0.0.0.0/0", "::/0"] } }
The droplet has a known good state from minute zero. If it dies, terraform apply rebuilds it in eight minutes flat. The only state I have to restore by hand is the docker volumes, which live on a separately backed up DO Spaces bucket.
Plan and apply flow
Pull requests get an automatic terraform plan as a comment. Merges to main run terraform apply with a manual approval gate. No human ever runs apply from a laptop against prod; that's how secrets end up in shell history.
yaml# .github/workflows/terraform.yml name: terraform on: pull_request: paths: ['envs/**', 'modules/**'] push: branches: [main] paths: ['envs/**', 'modules/**'] jobs: plan: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 with: terraform_version: 1.9.5 cli_config_credentials_token: ${{ secrets.TF_CLOUD_TOKEN }} - run: terraform -chdir=envs/prod init -input=false - run: terraform -chdir=envs/prod plan -no-color -out=plan.bin env: TF_VAR_db_password: ${{ secrets.SUPABASE_DB_PASSWORD }} - run: terraform -chdir=envs/prod show -no-color plan.bin > plan.txt - uses: actions/github-script@v7 with: script: | const fs = require('fs') const plan = fs.readFileSync('plan.txt', 'utf8').slice(0, 60000) github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: '\`\`\`hcl\n' + plan + '\n\`\`\`', }) apply: if: github.event_name == 'push' runs-on: ubuntu-latest environment: production # requires manual approval steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 with: terraform_version: 1.9.5 cli_config_credentials_token: ${{ secrets.TF_CLOUD_TOKEN }} - run: terraform -chdir=envs/prod init -input=false - run: terraform -chdir=envs/prod apply -auto-approve env: TF_VAR_db_password: ${{ secrets.SUPABASE_DB_PASSWORD }}
The "environment: production" line is the magic bit. GitHub Environments let you require a manual click before any job in that environment runs. One reviewer (me) clicks approve in the Actions UI; the apply runs. Every change is recorded in the run history.
Pitfalls
Once Terraform owns a resource, you stop editing it in the console. The day you forget and click "Add DNS record" in Cloudflare, your next plan tries to delete it. Add a CODEOWNERS rule on /modules/cloudflare and treat console edits as breaking the build.
Deploy hook URLs, webhook secrets, and database passwords leak into PR comments the moment they end up in an output without `sensitive = true`. Audit your outputs/* with `terraform output` locally before pushing.
The most common half-measure: Vercel and Supabase in Terraform, DNS still in the Cloudflare dashboard. DNS is precisely the thing you most want diffable. Migrate it on day one; the import is a single `terraform import` per record.
When the Vercel API has an outage, your Cloudflare DNS change should still ship. Split prod into two workspaces (edge: Cloudflare + DO; app: Vercel + Supabase) so one provider going down does not block the other.
Terraform does not understand ordering beyond resource graph, has no migration history, and cannot roll back. Use the Supabase CLI, Drizzle Kit, or sqitch for schema. Terraform owns the project; migrations own the schema.
Wrap up
The whole point of this stack is that it stays small. 200 lines of HCL across four modules cover every piece of cloud config sarmalinux.com depends on. The plan output on a normal week is empty. The plan output on a change week is six lines and obvious. That is the bar.
If your Terraform repo starts looking like 3000 lines and a 40 minute plan, you are putting things in it that should live somewhere else. Pull them out. The job of IaC for a solo founder is to be the diary you can git log, not the platform you spend Sundays maintaining. The full working repo, exactly as I run it, is at github.com/sarmakska/terraform-stack.
Want this done for you?
If you would rather skip the YAK shave and have someone who has done this fifty times set it up properly, that is what I do for a living.
Start a project