All posts

28 Oct 2026 · Docker & Infra

Terraform for Hetzner Cloud, Provisioning Next.js and Docker from Code

How to create a VPS, firewall, and cloud-init that installs Docker Compose so a Next.js 15 app boots without clicking through the Hetzner UI.

Clicking a CX23 in the Hetzner console works once. The second time you forget the firewall, leave port 3000 public, or skip Docker. Terraform makes the server, the firewall, and the first-boot script a file you can review.

AgenticPacks OSS is the Next.js app and Compose. It has no Terraform and no Caddy: Agentic-Packs/agenticpacks-oss. Pro ships Hetzner Terraform, cloud-init, Caddy auto-SSL, and the Compose files that run on that box. The image layout is in Deploying Next.js 15 with Docker Compose and Caddy.

The problem

A production VPS for this stack needs:

  • A public IPv4
  • SSH from your IP, not from the world on password auth
  • Ports 80 and 443 open, 3000 and 5432 closed
  • Docker and Compose installed
  • The git checkout or image pull, then docker compose up

Doing that by hand is how you get a Postgres port on the internet. The self-hosting cost post assumes this box already exists. This post is how the box gets there.

Provider and server

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

provider "hcloud" {
  token = var.hcloud_token
}

resource "hcloud_server" "app" {
  name        = "ai-starter"
  server_type = "cx23"
  image       = "ubuntu-24.04"
  location    = "nbg1"
  ssh_keys    = [var.ssh_key_id]
  user_data   = file("${path.module}/cloud-init.yaml")
  firewall_ids = [hcloud_firewall.app.id]
}

CX23 is enough for Next.js standalone, Postgres, and Caddy. If next build runs on this node you still need swap. Prefer building the image elsewhere. See preventing SIGKILL.

The API token is a Terraform variable. It is not in git.

Firewall

resource "hcloud_firewall" "app" {
  name = "ai-starter"
  rule {
    direction = "in"
    protocol  = "tcp"
    port      = "22"
    source_ips = [var.admin_cidr]
  }
  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"]
  }
}

Do not open 3000. Caddy reaches web:3000 on the Docker network. Do not open 5432. Postgres stays internal. If you need Grafana, do not publish 3005 to 0.0.0.0. See Prometheus and Grafana.

cloud-init

First boot installs Docker and clones or pulls. A short form:

#cloud-config
package_update: true
packages:
  - docker.io
  - docker-compose-v2
runcmd:
  - [ systemctl, enable, --now, docker ]

Pro's cloud-init also writes Compose env files from Terraform variables, starts Caddy, and does not print secrets in cloud-init logs. Keep DATABASE_URL and Stripe keys in runtime env, not in the image. That split is in the Docker env guide.

DNS: create the A record for the hostname Caddy will request a cert for. Grey-cloud at Cloudflare until ACME succeeds, then Full (strict). That sequence is in the Caddy SSE post.

Tired of recreating firewall rules by memory?

The app you deploy is open-source: Agentic-Packs/agenticpacks-oss.

Terraform, cloud-init, Caddy TLS, and Compose on Hetzner are in Hetzner Terraform and Caddy IaC in Pro.

What Terraform is not

It is not Kubernetes. It is not multi-region failover. It is one reviewed file that creates one production-shaped VPS. If you outgrow a CX23, you change server_type and plan again, or you add a second server for Postgres. The 25k MAU cost post is when that conversation starts.


Where to Go From Here

You do not have to click through Hetzner every time you want a clean box.

Option A: Free open-source baseline (MIT)

  • GitHub: github.com/Agentic-Packs/agenticpacks-oss
  • What is included: Next.js 15, Compose, Postgres, Auth.js, streaming chat, single-plan Stripe. No Terraform, no Caddy.
  • Cost: free, MIT. You provision the VPS yourself.

Option B: Ship with AgenticPacks Pro ($149 one-time)

If terraform apply should be the provisioner:

  • Automated Hetzner provisioning: server, firewall, cloud-init, Caddy SSL, Compose.
  • Production multi-agent core: LangGraph, pgvector, Redis.
  • Usage & Unit Economics: Stripe, team pools, token ledger.