{
  "$type": "site.standard.document",
  "description": "I use Terraform and Kamal 2 to provision and deploy my SaaS apps. The main reason is cost control — hosting one app on a PaaS like Render or Vercel",
  "path": "/terraform-for-indie-hackers-just-enough-infrastructure-as-code/",
  "publishedAt": "2026-04-06T07:09:00.000Z",
  "site": "at://did:plc:bryys25pc2fnagnyxqgsglhd/site.standard.publication/3mn26bjkkmh23",
  "tags": [
    "Tools",
    "Web"
  ],
  "textContent": "I use Terraform and Kamal 2 to provision and deploy my SaaS apps. The main reason is cost control — hosting one app on a PaaS like Render or Vercel can be fine, but it gets tough when I'm experimenting with a few of them and they don't all make money yet.\n\nWHY BOTHER\n\nWith Terraform, the entire server setup is a file. Run terraform apply, get the exact same server. Same config, same firewall rules, same SSH keys.\n\nTHE MINIMAL SETUP\n\nMy Terraform config for a single Hetzner server running a SaaS app:\n\ninfra/\n  main.tf          # provider and server resources\n  variables.tf     # configurable values\n  outputs.tf       # values to display after apply\n  terraform.tfvars # actual values (not committed)\n\nFour files. That's it.\n\nPROVIDER SETUP\n\nterraform {\n  required_providers {\n    hcloud = {\n      source  = \"hetznercloud/hcloud\"\n      version = \"~> 1.45\"\n    }\n  }\n}\n\nprovider \"hcloud\" {\n  token = var.hcloud_token\n}\n\nTHE SERVER\n\nresource \"hcloud_server\" \"app\" {\n  name        = \"myapp-prod\"\n  image       = \"ubuntu-24.04\"\n  server_type = \"cax21\"\n  location    = \"fsn1\"\n  ssh_keys    = [hcloud_ssh_key.default.id]\n\n  user_data = file(\"cloud-init.yml\")\n}\n\nresource \"hcloud_ssh_key\" \"default\" {\n  name       = \"default\"\n  public_key = file(\"~/.ssh/id_ed25519.pub\")\n}\n\ncax21 is the ARM instance — 4 vCPU, 8GB RAM, ~€8/month. user_data is a cloud-init script that runs on first boot.\n\nCLOUD-INIT: SERVER BOOTSTRAP\n\n#cloud-config\npackages:\n  - docker.io\n  - docker-compose-plugin\n  - fail2ban\n  - ufw\n\nruncmd:\n  - systemctl enable docker\n  - systemctl start docker\n  - ufw allow 22/tcp\n  - ufw allow 80/tcp\n  - ufw allow 443/tcp\n  - ufw --force enable\n  - fallocate -l 2G /swapfile\n  - chmod 600 /swapfile\n  - mkswap /swapfile\n  - swapon /swapfile\n  - echo '/swapfile none swap sw 0 0' >> /etc/fstab\n\nInstalls Docker, sets up the firewall (SSH, HTTP, HTTPS only), enables fail2ban, and creates swap. When the server boots, it's ready for Kamal to deploy to.\n\nVARIABLES\n\nvariable \"hcloud_token\" {\n  description = \"Hetzner API token\"\n  sensitive   = true\n}\n\nThe actual token goes in terraform.tfvars (never committed):\n\nhcloud_token = \"your-token-here\"\n\nOUTPUTS\n\noutput \"server_ip\" {\n  value = hcloud_server.app.ipv4_address\n}\n\nAfter terraform apply, it prints the server IP. I copy that into Kamal's deploy.yml and deploy.\n\nTHE WORKFLOW\n\ncd infra\nterraform init      # first time only\nterraform plan      # preview what will change\nterraform apply     # create/update the server\n\nterraform plan shows exactly what will be created, changed, or destroyed before you confirm.\n\nFor a fresh project:\n\n 1. terraform apply — creates the server\n 2. Copy the server IP to Kamal's deploy.yml\n 3. kamal setup — first deploy, sets up kamal-proxy and containers\n 4. kamal deploy — subsequent deploys\n\nDNS RECORDS\n\nI manage DNS through Cloudflare and add those records to Terraform too:\n\nresource \"cloudflare_dns_record\" \"app\" {\n  zone_id = var.cloudflare_zone_id\n  name    = \"myapp.com\"\n  content = hcloud_server.app.ipv4_address\n  type    = \"A\"\n  proxied = true\n}\n\nresource \"cloudflare_dns_record\" \"www\" {\n  zone_id = var.cloudflare_zone_id\n  name    = \"www\"\n  content = \"myapp.com\"\n  type    = \"CNAME\"\n  proxied = true\n}\n\nterraform apply creates the server and points the domain at it. One command.\n\nFIREWALL RULES\n\nHetzner has cloud firewalls, also manageable via Terraform:\n\nresource \"hcloud_firewall\" \"app\" {\n  name = \"app-firewall\"\n\n  rule {\n    direction = \"in\"\n    protocol  = \"tcp\"\n    port      = \"22\"\n    source_ips = [\"0.0.0.0/0\", \"::/0\"]\n  }\n\n  rule {\n    direction = \"in\"\n    protocol  = \"tcp\"\n    port      = \"80\"\n    source_ips = [\"0.0.0.0/0\", \"::/0\"]\n  }\n\n  rule {\n    direction = \"in\"\n    protocol  = \"tcp\"\n    port      = \"443\"\n    source_ips = [\"0.0.0.0/0\", \"::/0\"]\n  }\n}\n\nresource \"hcloud_firewall_attachment\" \"app\" {\n  firewall_id = hcloud_firewall.app.id\n  server_ids  = [hcloud_server.app.id]\n}\n\nDefense in depth — the cloud firewall blocks traffic before it reaches the server, UFW on the server is the second layer.\n\nSTATE MANAGEMENT\n\nTerraform tracks what it created in a state file (terraform.tfstate). This maps your config to real resources — it knows hcloud_server.app is server ID 12345678 on Hetzner.\n\nFor one or two servers, the local state file is fine. Keep it out of version control (it contains sensitive data) and back it up. Lose the state file and Terraform doesn't know what it created — you'd have to import resources manually or start fresh.\n\nFor remote state shared across machines, Terraform supports S3-compatible backends. Hetzner doesn't have one, but Cloudflare R2 works:\n\nterraform {\n  backend \"s3\" {\n    bucket = \"terraform-state\"\n    key    = \"myapp/terraform.tfstate\"\n    region = \"auto\"\n    endpoints = {\n      s3 = \"https://ACCOUNT_ID.r2.cloudflarestorage.com\"\n    }\n    skip_credentials_validation = true\n    skip_metadata_api_check     = true\n    skip_requesting_account_id  = true\n    skip_region_validation      = true\n    skip_s3_checksum            = true\n    use_path_style              = true\n  }\n}\n\nI keep the state file locally and back it up. Remote state is more complexity than I need.\n\nWHAT I DON'T USE TERRAFORM FOR\n\n * Application deployment — that's Kamal's job\n * Database management — PostgreSQL runs in a Docker container managed by Kamal\n * SSL certificates — Let's Encrypt via kamal-proxy, also Kamal\n * Monitoring — I stream logs to BetterStack\n\nTerraform provisions the box. Everything that runs on it is managed by other tools.\n\nGETTING STARTED\n\n 1. Install Terraform (brew install terraform on macOS)\n 2. Get a Hetzner API token from the Cloud Console\n 3. Create the four files above, adjusted for your server type and SSH key\n 4. Run terraform init && terraform apply\n 5. Point your domain at the server IP\n 6. Deploy your app with Kamal\n\nThat's it. One server, one config, one command. Add complexity later if you need it — but for a SaaS serving hundreds or thousands of users, this is more than enough.",
  "title": "Terraform for Indie Hackers: Just Enough Infrastructure as Code"
}