Reusable Terraform modules for provisioning infrastructure on Hetzner Cloud with Cloudflare integration.
- Terraform; the supported optional Nix devShell pins it (
direnv allowornix develop), but a compatible native installation also works - Hetzner Cloud API token
- Cloudflare API token (for DNS/auth modules)
- S3 credentials (for object storage module)
| Module | Description |
|---|---|
| hetzner/network | VPC and subnet |
| hetzner/firewall | Ingress firewall rules |
| hetzner/server-stateful | Database servers with delete protection and backups |
| hetzner/server-stateless | Web/app servers (ephemeral) |
| hetzner/object-storage | S3-compatible buckets |
| cloudflare/dns | DNS records |
| cloudflare/auth | Worker-based HTTP auth and noindex headers |
| cloudflare/redirect | Per-host 301/302 redirects via Single Redirects |
Full infrastructure setup with database, web servers, DNS, and storage:
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.45"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.0"
}
minio = {
source = "aminueza/minio"
version = "~> 2.0"
}
}
}
provider "hcloud" {
token = var.hcloud_token
}
provider "cloudflare" {
api_token = var.cloudflare_token
}
# SSH Key
resource "hcloud_ssh_key" "default" {
name = "deploy-key"
public_key = file("~/.ssh/id_rsa.pub")
}
# Network
module "network" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/network?ref=v1.0.0"
name = "production"
ip_range = "10.0.0.0/8"
zone = "nbg1"
}
# Database Server (stateful)
module "database" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/server-stateful?ref=v1.0.0"
name = "postgres"
type = "cx32"
zone = "nbg1"
instances_count = 1
network_id = module.network.vpc_id
ssh_key_resource = hcloud_ssh_key.default
additional_firewall_rules = [
{ protocol = "tcp", port = 5432 } # PostgreSQL
]
}
# Web Servers (stateless)
module "web" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/server-stateless?ref=v1.0.0"
name = "web"
type = "cx22"
zone = "nbg1"
instances_count = 2
network_id = module.network.vpc_id
ssh_key_resource = hcloud_ssh_key.default
}
# DNS Records
module "dns" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/cloudflare/dns?ref=v1.0.0"
token = var.cloudflare_token
zone = "example.com"
name = "production"
records = {
root = {
value = module.web.server_ips[0]
type = "A"
proxied = true
}
www = {
name = "www"
value = module.web.server_ips[0]
type = "A"
proxied = true
}
staging = {
name = "staging"
value = module.web.server_ips[1]
type = "A"
proxied = true
}
}
}
# Auth Protection for Staging
module "auth" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/cloudflare/auth?ref=v1.0.0"
token = var.cloudflare_token
zone = "example.com"
name = "staging-protection"
shared_auth_user = var.staging_user
shared_auth_password = var.staging_password
protected_subdomains = [
{ name = "staging", auth = true, noindex = true }
]
}
# Object Storage
module "storage" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/object-storage?ref=v1.0.0"
s3_access_key = var.s3_access_key
s3_secret_key = var.s3_secret_key
zone = "nbg1"
buckets = {
assets = {
name = "myapp-assets"
acl = "public-read"
}
backups = {
name = "myapp-backups"
acl = "private"
}
}
}Creates a VPC with a /24 subnet.
module "network" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/network?ref=v1.0.0"
name = "production"
ip_range = "10.0.0.0/8"
zone = "nbg1" # optional, default: nbg1
}| Variable | Required | Default | Description |
|---|---|---|---|
name |
yes | - | Name identifier |
ip_range |
yes | - | VPC CIDR block (e.g., "10.0.0.0/8") |
zone |
no | nbg1 |
Hetzner datacenter |
| Output | Description |
|---|---|
vpc_id |
Network ID (pass to server modules) |
vpc_subnet_id |
Subnet ID |
Database/stateful servers with delete protection and automatic backups enabled.
module "database" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/server-stateful?ref=v1.0.0"
name = "postgres"
type = "cx32"
network_id = module.network.vpc_id
ssh_key_resource = hcloud_ssh_key.default
# Optional
zone = "nbg1"
instances_count = 1
os = "ubuntu-24.04"
enable_backups = true
additional_firewall_rules = [
{ protocol = "tcp", port = 5432 }, # PostgreSQL
{ protocol = "tcp", port = 6379 } # Redis
]
}| Variable | Required | Default | Description |
|---|---|---|---|
name |
yes | - | Name identifier |
type |
yes | - | Server type (cx22, cx32, cx42, etc.) |
network_id |
yes | - | VPC ID from network module |
ssh_key_resource |
yes | - | hcloud_ssh_key resource |
zone |
no | nbg1 |
Datacenter |
instances_count |
no | 1 |
Number of servers |
os |
no | ubuntu-24.04 |
OS image |
enable_backups |
no | true |
Enable Hetzner backups |
additional_firewall_rules |
no | [] |
Extra ports to open |
| Output | Description |
|---|---|
server_ips |
List of public IPv4 addresses |
Notes:
- Delete and rebuild protection enabled
- Lifecycle
prevent_destroyis set - Base firewall opens port 937 (SSH)
Web/application servers designed for ephemeral workloads.
module "web" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/server-stateless?ref=v1.0.0"
name = "web"
type = "cx22"
network_id = module.network.vpc_id
ssh_key_resource = hcloud_ssh_key.default
# Optional
zone = "nbg1"
instances_count = 3
prevent_destroy = false
}| Variable | Required | Default | Description |
|---|---|---|---|
name |
yes | - | Name identifier |
type |
yes | - | Server type |
network_id |
yes | - | VPC ID from network module |
ssh_key_resource |
yes | - | hcloud_ssh_key resource |
zone |
no | nbg1 |
Datacenter |
instances_count |
no | 1 |
Number of servers |
os |
no | ubuntu-24.04 |
OS image |
prevent_destroy |
no | false |
Enable delete protection |
| Output | Description |
|---|---|
server_ips |
List of public IPv4 addresses |
Notes:
- No automatic backups (unlike stateful)
- Base firewall opens ports 80, 443, 937
S3-compatible object storage buckets.
module "storage" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/object-storage?ref=v1.0.0"
s3_access_key = var.s3_access_key
s3_secret_key = var.s3_secret_key
zone = "nbg1"
buckets = {
public_assets = {
name = "myapp-public"
acl = "public-read"
}
private_data = {
name = "myapp-private"
acl = "private"
}
}
}| Variable | Required | Default | Description |
|---|---|---|---|
s3_access_key |
yes | - | S3 access key |
s3_secret_key |
yes | - | S3 secret key |
buckets |
yes | - | Map of bucket definitions |
zone |
no | nbg1 |
Object storage region |
Bucket ACL options: private, public-read, public-read-write
| Output | Description |
|---|---|
bucket_names |
Map of bucket names |
bucket_urls |
Map of bucket domain URLs |
public_bucket_urls |
URLs for public buckets only |
endpoint |
S3 endpoint URL |
DNS record management.
module "dns" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/cloudflare/dns?ref=v1.0.0"
token = var.cloudflare_token
zone = "example.com"
name = "production"
records = {
root = {
value = "1.2.3.4"
type = "A"
proxied = true
}
www = {
name = "www"
value = "1.2.3.4"
type = "CNAME"
proxied = true
}
mail = {
name = "mail"
value = "mail.provider.com"
type = "MX"
priority = 10
}
}
}| Variable | Required | Default | Description |
|---|---|---|---|
token |
yes | - | Cloudflare API token |
zone |
yes | - | Domain name |
name |
yes | - | Resource identifier |
records |
yes | - | Map of DNS records |
Record fields:
value(required): Record valuetype(required): A, AAAA, CNAME, MX, TXT, etc.name(optional): Subdomain, default "@" (root)proxied(optional): Enable Cloudflare proxyttl(optional): Time-to-livepriority(optional): For MX/SRV records
HTTP Basic Auth and/or noindex headers via Cloudflare Workers.
module "auth" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/cloudflare/auth?ref=v1.0.0"
token = var.cloudflare_token
zone = "example.com"
name = "staging-auth"
shared_auth_user = var.auth_user
shared_auth_password = var.auth_password
protected_subdomains = [
{ name = "staging", auth = true, noindex = true }, # Full protection
{ name = "dev", auth = true, noindex = false }, # Auth only
{ name = "preview", auth = false, noindex = true } # Noindex only
]
}| Variable | Required | Default | Description |
|---|---|---|---|
token |
yes | - | Cloudflare API token |
zone |
yes | - | Domain name |
name |
yes | - | Resource identifier |
shared_auth_user |
yes | - | HTTP Basic Auth username |
shared_auth_password |
yes | - | HTTP Basic Auth password |
protected_subdomains |
yes | - | List of subdomains to protect |
Subdomain options:
name(required): Subdomain nameauth(optional, default: true): Enable HTTP Basic Authnoindex(optional, default: true): Add X-Robots-Tag noindex header
Notes:
.well-knownpaths are excluded from auth (for ACME validation)- Three worker scripts are deployed based on auth/noindex combinations
Per-host URL redirects implemented as a Cloudflare Single Redirects ruleset (http_request_dynamic_redirect phase, zone-scoped). Use this to retire a domain by 301-ing some or all of its hosts to a replacement, preserving path and query string. Each map entry produces one rule inside a single ruleset.
module "redirect" {
source = "git::https://github.com/marcortola/terraform-modules.git//modules/cloudflare/redirect?ref=v1.6.0"
token = var.cloudflare_token
zone = "old-domain.com"
name = "old-domain"
rules = {
apex = {
source_host = "@"
target_url = "https://new-domain.com"
}
www = {
source_host = "www"
target_url = "https://new-domain.com"
}
clubs = {
source_host = "clubs"
target_url = "https://clubs.new-domain.com"
}
api = {
source_host = "api"
target_url = "https://api.new-domain.com"
placeholder_dns = true # host has no real origin; module creates the proxied record
}
}
}| Variable | Required | Default | Description |
|---|---|---|---|
token |
yes | - | Cloudflare API token |
zone |
yes | - | Source domain (the one to redirect from) |
name |
yes | - | Resource identifier |
rules |
no | {} |
Map of redirect rules |
Rule fields:
source_host(required): subdomain label (e.g.www) or@for the apextarget_url(required): absolute destination URL (e.g.https://new-domain.com)preserve_path(optional, defaulttrue): append the original path totarget_urlpreserve_query(optional, defaulttrue): forward the original query stringstatus_code(optional, default301): HTTP redirect status (301, 302, 307, 308)placeholder_dns(optional, defaultfalse): whentrue, the module creates a proxied A record forsource_hostpointing at192.0.2.1, so the redirect fires for a host with no real origin (see notes). Leavefalsefor any host that already resolves to a real origin — otherwise the placeholder collides with the existing record.
Notes:
- Cloudflare allows only one ruleset per zone per phase — do not instantiate this module twice against the same
zone. - The host must be proxied (orange-cloud) at the DNS layer for the redirect to fire. A Dynamic Redirect rule only acts on traffic Cloudflare actually proxies. For a host with no real origin, set
placeholder_dns = trueand the module creates a proxied A record at192.0.2.1(RFC 5737 TEST-NET) for you — unroutable, never contacted, but enough to route the request through the edge where the redirect short-circuits it. (Equivalently you can create that record yourself in thecloudflare/dnsmodule and leaveplaceholder_dns = false.) - Using
placeholder_dns = truerequires the API token to also holdZone → DNS → Editon the source zone, in addition to the Single Redirect permission below. - Do not set
placeholder_dns = trueforsource_host = "@"(apex) when the zone already has an apex record — the placeholder will collide with it. - The rule fires before origin: no backend is contacted.
- Use
preserve_path = falsetogether with a fully-qualifiedtarget_url(e.g.https://new-domain.com/welcome) when you want every request on the source host to land on a fixed path. - Required Cloudflare API token permission:
Zone → Single Redirect → Editon the source zone, with Zone Resources scoped to include that zone. Without it,terraform applyfails withrequest is not authorized. (Zone → Config Rules,Zone → Transform Rules, andZone → WAFpermissions do NOT cover thehttp_request_dynamic_redirectphase used by this module.)
All servers are provisioned with cloud-init that:
- Creates a
kamaluser (UID 1000) with Docker and sudo access - Configures SSH on port 937 (not 22)
- Disables root login and password authentication
- Installs Docker, git, curl, htop, ntp
- Enables unattended security upgrades
- Disables IPv6
SSH connection:
ssh -p 937 kamal@<server-ip>| Type | vCPU | RAM | Use Case |
|---|---|---|---|
| cx22 | 2 | 4 GB | Small web apps |
| cx32 | 4 | 8 GB | Medium workloads |
| cx42 | 8 | 16 GB | Databases, heavy apps |
| cx52 | 16 | 32 GB | Large databases |
| Zone | Location |
|---|---|
| nbg1 | Nuremberg, Germany |
| fsn1 | Falkenstein, Germany |
| hel1 | Helsinki, Finland |
Always pin to a specific version:
source = "git::https://github.com/marcortola/terraform-modules.git//modules/hetzner/network?ref=v1.0.0"