Terraform for Indie Hackers: Just Enough Infrastructure as Code
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:
terraform apply— creates the server- Copy the server IP to Kamal’s
deploy.yml kamal setup— first deploy, sets up kamal-proxy and containerskamal 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
- Install Terraform (
brew install terraformon macOS) - Get a Hetzner API token from the Cloud Console
- Create the four files above, adjusted for your server type and SSH key
- Run
terraform init && terraform apply - Point your domain at the server IP
- 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.