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.

Why Bother

With Terraform, the entire server setup is a file. Run terraform apply, get the exact same server. Same config, same firewall rules, same SSH keys.

The Minimal Setup

My Terraform config for a single Hetzner server running a SaaS app:

infra/
  main.tf          # provider and server resources
  variables.tf     # configurable values
  outputs.tf       # values to display after apply
  terraform.tfvars # actual values (not committed)

Four files. That’s it.

Provider Setup

terraform {
  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.45"
    }
  }
}

provider "hcloud" {
  token = var.hcloud_token
}

The Server

resource "hcloud_server" "app" {
  name        = "myapp-prod"
  image       = "ubuntu-24.04"
  server_type = "cax21"
  location    = "fsn1"
  ssh_keys    = [hcloud_ssh_key.default.id]

  user_data = file("cloud-init.yml")
}

resource "hcloud_ssh_key" "default" {
  name       = "default"
  public_key = file("~/.ssh/id_ed25519.pub")
}

cax21 is the ARM instance — 4 vCPU, 8GB RAM, ~€8/month. user_data is a cloud-init script that runs on first boot.

Cloud-Init: Server Bootstrap

#cloud-config
packages:
  - docker.io
  - docker-compose-plugin
  - fail2ban
  - ufw

runcmd:
  - systemctl enable docker
  - systemctl start docker
  - ufw allow 22/tcp
  - ufw allow 80/tcp
  - ufw allow 443/tcp
  - ufw --force enable
  - fallocate -l 2G /swapfile
  - chmod 600 /swapfile
  - mkswap /swapfile
  - swapon /swapfile
  - echo '/swapfile none swap sw 0 0' >> /etc/fstab

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

Variables

variable "hcloud_token" {
  description = "Hetzner API token"
  sensitive   = true
}

The actual token goes in terraform.tfvars (never committed):

hcloud_token = "your-token-here"

Outputs

output "server_ip" {
  value = hcloud_server.app.ipv4_address
}

After terraform apply, it prints the server IP. I copy that into Kamal’s deploy.yml and deploy.

The Workflow

cd infra
terraform init      # first time only
terraform plan      # preview what will change
terraform apply     # create/update the server

terraform plan shows exactly what will be created, changed, or destroyed before you confirm.

For a fresh project:

  1. terraform apply — creates the server
  2. Copy the server IP to Kamal’s deploy.yml
  3. kamal setup — first deploy, sets up kamal-proxy and containers
  4. kamal deploy — subsequent deploys

DNS Records

I manage DNS through Cloudflare and add those records to Terraform too:

resource "cloudflare_dns_record" "app" {
  zone_id = var.cloudflare_zone_id
  name    = "myapp.com"
  content = hcloud_server.app.ipv4_address
  type    = "A"
  proxied = true
}

resource "cloudflare_dns_record" "www" {
  zone_id = var.cloudflare_zone_id
  name    = "www"
  content = "myapp.com"
  type    = "CNAME"
  proxied = true
}

terraform apply creates the server and points the domain at it. One command.

Firewall Rules

Hetzner has cloud firewalls, also manageable via Terraform:

resource "hcloud_firewall" "app" {
  name = "app-firewall"

  rule {
    direction = "in"
    protocol  = "tcp"
    port      = "22"
    source_ips = ["0.0.0.0/0", "::/0"]
  }

  rule {
    direction = "in"
    protocol  = "tcp"
    port      = "80"
    source_ips = ["0.0.0.0/0", "::/0"]
  }

  rule {
    direction = "in"
    protocol  = "tcp"
    port      = "443"
    source_ips = ["0.0.0.0/0", "::/0"]
  }
}

resource "hcloud_firewall_attachment" "app" {
  firewall_id = hcloud_firewall.app.id
  server_ids  = [hcloud_server.app.id]
}

Defense in depth — the cloud firewall blocks traffic before it reaches the server, UFW on the server is the second layer.

State Management

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

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

For remote state shared across machines, Terraform supports S3-compatible backends. Hetzner doesn’t have one, but Cloudflare R2 works:

terraform {
  backend "s3" {
    bucket = "terraform-state"
    key    = "myapp/terraform.tfstate"
    region = "auto"
    endpoints = {
      s3 = "https://ACCOUNT_ID.r2.cloudflarestorage.com"
    }
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    skip_requesting_account_id  = true
    skip_region_validation      = true
    skip_s3_checksum            = true
    use_path_style              = true
  }
}

I keep the state file locally and back it up. Remote state is more complexity than I need.

What I Don’t Use Terraform For

  • Application deployment — that’s Kamal’s job
  • Database management — PostgreSQL runs in a Docker container managed by Kamal
  • SSL certificates — Let’s Encrypt via kamal-proxy, also Kamal
  • Monitoring — I stream logs to BetterStack

Terraform provisions the box. Everything that runs on it is managed by other tools.

Getting Started

  1. Install Terraform (brew install terraform on macOS)
  2. Get a Hetzner API token from the Cloud Console
  3. Create the four files above, adjusted for your server type and SSH key
  4. Run terraform init && terraform apply
  5. Point your domain at the server IP
  6. Deploy your app with Kamal

That’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.