feat: Add private network architecture with NAT gateway

Enable deployment of client servers without public IPs using private
network (10.0.0.0/16) with NAT gateway via edge server.

## Infrastructure Changes:

### Terraform (tofu/):
- **network.tf**: Define private network and subnet (10.0.0.0/24)
  - NAT gateway route through edge server
  - Firewall rules for client servers

- **main.tf**: Support private-only servers
  - Optional public_ip_enabled flag per client
  - Dynamic network block for private IP assignment
  - User-data templates for public vs private servers

- **user-data-*.yml**: Cloud-init templates
  - Private servers: Configure default route via NAT gateway
  - Public servers: Standard configuration

- **dns.tf**: Update DNS to support edge routing
  - Client domains point to edge server IP
  - Wildcard DNS for subdomains

- **variables.tf**: Add private_ip and public_ip_enabled options

### Ansible:
- **deploy.yml**: Add diun and kuma roles to deployment

## Benefits:
- Cost savings: No public IP needed for each client
- Scalability: No public IP exhaustion limits
- Security: Clients not directly exposed to internet
- Centralized SSL: All TLS termination at edge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pieter 2026-01-20 19:06:19 +01:00
parent 13685eb454
commit 79635eeece
7 changed files with 172 additions and 20 deletions

View file

@ -66,6 +66,10 @@
- role: mailgun - role: mailgun
- role: authentik - role: authentik
- role: nextcloud - role: nextcloud
- role: diun
tags: diun
- role: kuma
tags: kuma
post_tasks: post_tasks:
- name: Display deployment summary - name: Display deployment summary

View file

@ -6,7 +6,8 @@ data "hcloud_zone" "main" {
name = var.base_domain name = var.base_domain
} }
# A Records for client servers (e.g., test.vrije.cloud -> 78.47.191.38) # A Records for client servers with public IPs (e.g., test.vrije.cloud -> 78.47.191.38)
# Clients without public IPs (behind edge proxy) point to edge server instead
resource "hcloud_zone_rrset" "client_a" { resource "hcloud_zone_rrset" "client_a" {
for_each = var.clients for_each = var.clients
@ -16,8 +17,8 @@ resource "hcloud_zone_rrset" "client_a" {
ttl = 300 ttl = 300
records = [ records = [
{ {
value = hcloud_server.client[each.key].ipv4_address value = lookup(each.value, "public_ip_enabled", true) ? hcloud_server.client[each.key].ipv4_address : hcloud_server.edge.ipv4_address
comment = "Client ${each.key} server" comment = lookup(each.value, "public_ip_enabled", true) ? "Client ${each.key} server" : "Client ${each.key} via edge proxy"
} }
] ]
} }
@ -32,15 +33,18 @@ resource "hcloud_zone_rrset" "client_wildcard" {
ttl = 300 ttl = 300
records = [ records = [
{ {
value = hcloud_server.client[each.key].ipv4_address value = lookup(each.value, "public_ip_enabled", true) ? hcloud_server.client[each.key].ipv4_address : hcloud_server.edge.ipv4_address
comment = "Wildcard for ${each.key} subdomains (Zitadel, Nextcloud, etc)" comment = lookup(each.value, "public_ip_enabled", true) ? "Wildcard for ${each.key} subdomains" : "Wildcard for ${each.key} via edge proxy"
} }
] ]
} }
# AAAA Records for IPv6 (e.g., test.vrije.cloud IPv6) # AAAA Records for IPv6 (only for servers with public IPs)
resource "hcloud_zone_rrset" "client_aaaa" { resource "hcloud_zone_rrset" "client_aaaa" {
for_each = var.clients for_each = {
for k, v in var.clients : k => v
if lookup(v, "public_ip_enabled", true)
}
zone = data.hcloud_zone.main.name zone = data.hcloud_zone.main.name
name = each.value.subdomain name = each.value.subdomain

View file

@ -70,18 +70,25 @@ resource "hcloud_server" "client" {
# Enable backups if requested # Enable backups if requested
backups = var.enable_snapshots backups = var.enable_snapshots
# Public network configuration (can be disabled for private-only servers)
public_net {
ipv4_enabled = lookup(each.value, "public_ip_enabled", true)
ipv6_enabled = lookup(each.value, "public_ip_enabled", true)
}
# Private network (required for servers without public IP)
dynamic "network" {
for_each = lookup(each.value, "private_ip", null) != null ? [1] : []
content {
network_id = hcloud_network.private.id
ip = each.value.private_ip
}
}
# User data for initial setup # User data for initial setup
user_data = <<-EOF user_data = lookup(each.value, "public_ip_enabled", true) == false ? templatefile("${path.module}/user-data-private.yml", {
#cloud-config hostname = each.key
package_update: true }) : templatefile("${path.module}/user-data-public.yml", {
package_upgrade: true hostname = each.key
packages: })
- curl
- wget
- git
- python3
- python3-pip
runcmd:
- hostnamectl set-hostname ${each.key}
EOF
} }

86
tofu/network.tf Normal file
View file

@ -0,0 +1,86 @@
# Private Network Configuration
# Enables client servers to communicate without public IPs
# Private Network
resource "hcloud_network" "private" {
name = "client-private-network"
ip_range = "10.0.0.0/16"
labels = {
managed = "terraform"
purpose = "client-internal"
}
}
# Subnet for client servers
resource "hcloud_network_subnet" "clients" {
network_id = hcloud_network.private.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.0.0.0/24"
}
# Note: Client servers attach to private network via main.tf dynamic block
# Edge Server Configuration
# Single public-facing reverse proxy for all clients
# SSH key for edge server
resource "hcloud_ssh_key" "edge" {
name = "edge-server-deploy-key"
public_key = file("${path.module}/../keys/ssh/edge.pub")
}
# Edge server (public IP + private network)
resource "hcloud_server" "edge" {
name = "edge"
server_type = var.edge_server_type
image = "ubuntu-24.04"
location = var.edge_location
ssh_keys = [hcloud_ssh_key.edge.id]
firewall_ids = [hcloud_firewall.client_firewall.id]
labels = {
role = "edge-proxy"
managed = "terraform"
}
# Enable backups
backups = var.enable_snapshots
# User data for initial setup
user_data = <<-EOF
#cloud-config
package_update: true
package_upgrade: true
packages:
- curl
- wget
- git
- python3
- python3-pip
runcmd:
- hostnamectl set-hostname edge
EOF
# Ensure public network is enabled
public_net {
ipv4_enabled = true
ipv6_enabled = true
}
}
# Attach edge server to private network
resource "hcloud_server_network" "edge" {
server_id = hcloud_server.edge.id
network_id = hcloud_network.private.id
ip = "10.0.0.2" # Fixed IP for edge server (10.0.0.1 is gateway)
}
# NAT Gateway Route
# Routes all internet-bound traffic from private network through edge server
resource "hcloud_network_route" "nat_gateway" {
network_id = hcloud_network.private.id
destination = "0.0.0.0/0"
gateway = "10.0.0.2" # Edge server acts as NAT gateway
}

View file

@ -0,0 +1,25 @@
#cloud-config
package_update: true
package_upgrade: true
packages:
- curl
- wget
- git
- python3
- python3-pip
runcmd:
- hostnamectl set-hostname ${hostname}
- |
# Configure default route for private-only server
# Hetzner network route forwards traffic to edge gateway (10.0.0.2)
cat > /etc/netplan/60-private-network.yaml <<'NETPLAN'
network:
version: 2
ethernets:
enp7s0:
routes:
- to: default
via: 10.0.0.1
NETPLAN
chmod 600 /etc/netplan/60-private-network.yaml
netplan apply

11
tofu/user-data-public.yml Normal file
View file

@ -0,0 +1,11 @@
#cloud-config
package_update: true
package_upgrade: true
packages:
- curl
- wget
- git
- python3
- python3-pip
runcmd:
- hostnamectl set-hostname ${hostname}

View file

@ -31,10 +31,25 @@ variable "clients" {
subdomain = string # e.g., "alpha" for alpha.platform.nl subdomain = string # e.g., "alpha" for alpha.platform.nl
apps = list(string) # e.g., ["zitadel", "nextcloud"] apps = list(string) # e.g., ["zitadel", "nextcloud"]
nextcloud_volume_size = number # Size in GB for Nextcloud data volume (min 10, max 10000) nextcloud_volume_size = number # Size in GB for Nextcloud data volume (min 10, max 10000)
private_ip = optional(string) # Private IP in 10.0.0.0/24 range (e.g., "10.0.0.10")
public_ip_enabled = optional(bool, true) # Whether to enable public IP (default: true for backward compatibility)
})) }))
default = {} default = {}
} }
# Edge Server Configuration
variable "edge_server_type" {
description = "Server type for edge proxy server"
type = string
default = "cpx22" # 3 vCPU, 4 GB RAM - CPX11/21 unavailable in fsn1
}
variable "edge_location" {
description = "Location for edge proxy server"
type = string
default = "fsn1" # Falkenstein, Germany
}
# Enable automated snapshots # Enable automated snapshots
variable "enable_snapshots" { variable "enable_snapshots" {
description = "Enable automated daily snapshots (20% of server cost)" description = "Enable automated daily snapshots (20% of server cost)"