The following Terraform configuration creates a private subnet scheme on Hetzner cloud consisting of the private networks / VPCs with the following CIDR ranges:
- VPC1 “10.10.0.0/16”
- VPC2 “10.20.0.0/16”
The servers connected to these VPCs access the internet via a VM based NAT gateway (that is also used as jump host) and do not require a public IP address.
Terraform Project #
File and Folder Structure #
The file and folder structure of the Terraform project looks like this:
hetzner-network
├── firewall.tf
├── gateway.tf
├── network.tf
├── terraform.tf
├── variables.tf
├── vm1.tf # VM in VPC1
└── vm2.tf # VM in VPC2
Project Folder #
# Create a folder for the Terraform project
mkdir hetzner-network && cd hetzner-network
Terraform Provider #
- terraform.tf
# Terraform Provider
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.50"
}
}
}
provider "hcloud" {
token = var.hcloud_token
}
Variables #
- variables.tf
# Hetzner Cloud Project token
variable "hcloud_token" {
sensitive = true
default = "YHvYhZ5xY29qOCNlgvEBRcgNZ0g389J4fXWNMKn95bq05naxkY2TJVNfOCvD2p9x"
}
Network #
- network.tf
# Network / VPC 1: CIDR "10.10.0.0/16"
resource "hcloud_network" "vpc1" {
name = "vpc-1"
ip_range = "10.10.0.0/16"
}
## Private Subnet 1: CIDR "10.10.0.0/24"
resource "hcloud_network_subnet" "vpc1_subnet1" {
network_id = hcloud_network.vpc1.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.10.0.0/24"
}
## Route to NAT gateway: "10.10.0.2"
resource "hcloud_network_route" "vpc1_route1" {
network_id = hcloud_network.vpc1.id
destination = "0.0.0.0/0"
gateway = "10.10.0.2" # Gateway
}
# Network / VPC 2: CIDR "10.20.0.0/16"
resource "hcloud_network" "vpc2" {
name = "vpc-2"
ip_range = "10.20.0.0/16"
}
## Private Subnet 2: CIDR "10.20.0.0/24"
resource "hcloud_network_subnet" "vpc2_subnet1" {
network_id = hcloud_network.vpc2.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.20.0.0/24"
}
## Route to NAT gateway: "10.20.0.2"
resource "hcloud_network_route" "vpc2_route1" {
network_id = hcloud_network.vpc2.id
destination = "0.0.0.0/0"
gateway = "10.20.0.2" # Gateway
}
Firewall #
- firewall.tf
# Firewall
resource "hcloud_firewall" "firewall1" {
name = "firewall1"
# ICMP Rule
rule {
direction = "in"
protocol = "icmp"
source_ips = [
"0.0.0.0/0",
"::/0"
]
}
# SSH Rule
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [
"0.0.0.0/0",
"::/0"
]
}
}
NAT Gateway / Jump Host #
- gateway.tf
# Gateway VM: Cloud-Init Configuration
locals {
gateway_cloud_init = <<-EOF
#cloud-config
write_files:
- path: /etc/network/interfaces
content: |
auto eth0
iface eth0 inet dhcp
post-up echo 1 > /proc/sys/net/ipv4/ip_forward
post-up iptables -t nat -A POSTROUTING -s '10.10.0.0/16' -o eth0 -j MASQUERADE
post-up iptables -t nat -A POSTROUTING -s '10.20.0.0/16' -o eth0 -j MASQUERADE
append: true
runcmd:
- reboot
EOF
}
# NAT Gateway
resource "hcloud_server" "gateway" {
name = "gateway-vm"
server_type = "cax11"
image = "debian-12"
location = "nbg1"
ssh_keys = ["ubuntu@hetznervm"]
labels = {
purpose = "Gateway"
}
# Public IP Configuration
public_net {
ipv4_enabled = true # Public IP for gateway
ipv6_enabled = false
}
# VPC1
network {
network_id = hcloud_network.vpc1.id
ip = "10.10.0.2" # Private network 1
}
# VPC2
network {
network_id = hcloud_network.vpc2.id
ip = "10.20.0.2" # Private network 2
}
# Hetzner Cloud firewall
firewall_ids = [hcloud_firewall.firewall1.id]
# Cloud-Init Configuration
user_data = local.gateway_cloud_init
depends_on = [
hcloud_network_subnet.vpc1_subnet1,
hcloud_network_subnet.vpc2_subnet1,
hcloud_firewall.firewall1
]
}
# Outputs
output "vm_gateway_public_ip" {
value = hcloud_server.gateway.ipv4_address
}
Network Overview:
-
-t nat
Use the NAT table -
-A POSTROUTING
Apply this rule after routing, before leaving the system -
-s 10.10.0.0/16
Source matches addresses from VPC -
-o eth0
Only apply this rule for traffic going out via the public interface -
-j MASQUERADE
Replace the source IP with the gateway’s public IP
VM in VPC1 #
- vm1.tf
# Private VMs: Cloud-Init Configuration
locals {
vpc1_cloud_init = <<-EOF
#cloud-config
write_files:
- path: /etc/network/interfaces
content: |
auto enp7s0
iface enp7s0 inet dhcp
post-up ip route add default via 10.10.0.1
append: true
- path: /etc/resolvconf/resolv.conf.d/head
content: |
nameserver 1.1.1.1
nameserver 8.8.8.8
append: true
runcmd:
- reboot
EOF
}
# Private VM 1 (Private Subnet 1)
resource "hcloud_server" "vm1" {
name = "vm1"
server_type = "cax11"
image = "debian-12"
location = "nbg1"
ssh_keys = ["ubuntu@hetznervm"]
labels = {
purpose = "VM"
}
# Public IP Configuration
public_net {
ipv4_enabled = false # Disable public IPv4 address
ipv6_enabled = false # Disable public IPv6 address
}
# Private Network Configuration
network {
network_id = hcloud_network.vpc1.id # VPC1
ip = "10.10.0.10" # Private IP
}
# Cloud-Init Configuration
user_data = local.vpc1_cloud_init
depends_on = [
hcloud_network_subnet.vpc1_subnet1
]
}
VM in VPC2 #
- vm2.tf
# Private VMs: Cloud-Init Configuration
locals {
vpc2_cloud_init = <<-EOF
#cloud-config
write_files:
- path: /etc/network/interfaces
content: |
auto enp7s0
iface enp7s0 inet dhcp
post-up ip route add default via 10.20.0.1
append: true
- path: /etc/resolvconf/resolv.conf.d/head
content: |
nameserver 1.1.1.1
nameserver 8.8.8.8
append: true
runcmd:
- reboot
EOF
}
# Private VM 1 (Private Subnet 1)
resource "hcloud_server" "vm2" {
name = "vm2"
server_type = "cax11"
image = "debian-12"
location = "nbg1"
ssh_keys = ["ubuntu@hetznervm"]
labels = {
purpose = "VM"
}
# Public IP Configuration
public_net {
ipv4_enabled = false # Disable public IPv4 address
ipv6_enabled = false # Disable public IPv6 address
}
# Private Network Configuration
network {
network_id = hcloud_network.vpc2.id # VPC1
ip = "10.20.0.10" # Private IP
}
# Cloud-Init Configuration
user_data = local.vpc2_cloud_init
depends_on = [
hcloud_network_subnet.vpc2_subnet1
]
}
Access VMs #
Apply Terraform Configuration #
# Initialize Terraform provider
terraform init
# Validate Terraform configuration
terraform validate
# Test the Terraform configuration
terraform plan
# Apply terraform config
terraform apply -auto-approve
# Shell output:
vm_gateway_public_ip = "142.132.167.98"
Remove Old Host Keys #
If necessary remove the old host keys:
# Remove old host-keys
ssh-keygen -R 142.132.167.98
ssh-keygen -R 10.10.0.10
ssh-keygen -R 10.20.0.10
Jump Host SSH Configuration #
# Open the SSH configuration (user specific)
vi ~/.ssh/config
# Jump Host Configuration
Host jump-host
HostName 142.132.167.98
User root
IdentityFile ~/.ssh/id_rsa
IdentitiesOnly yes
# VM in VPC1
Host vm1
HostName 10.10.0.10
User root
ProxyJump jump-host
IdentityFile ~/.ssh/id_rsa
IdentitiesOnly yes
# VM in VPC2
Host vm2
HostName 10.20.0.10
User root
ProxyJump jump-host
IdentityFile ~/.ssh/id_rsa
IdentitiesOnly yes
NAT Gateway / Jump Host Configuration #
Access NAT Gateway / Jump Host #
# Access NAT Gateway VM
ssh root@142.132.167.98
Optional copy the private SSH key to the NAT gateway / jump host VM (for testing purposes, not recommended in production):
# Copy the private SSH key to NAT Gateway VM
scp -i ~/.ssh/id_rsa ~/.ssh/id_rsa root@138.199.219.6:/root/.ssh/id_rsa
Block Traffic Between Networks #
The following rule blocks the traffic from VPC1 to VPC2, but still allows traffic from VPC2 to VPC1:
# Block network 1 to network 2
iptables -A FORWARD -s 10.10.0.0/24 -d 10.20.0.0/24 -m conntrack --ctstate NEW -j DROP
Remove rule:
# Block network 1 to network 2
iptables -D FORWARD -s 10.10.0.0/24 -d 10.20.0.0/24 -m conntrack --ctstate NEW -j DROP
The iptables are not persistent across reboots. To make the rules persistent, proceed as follows:
# Install the iptables-persistent package (the installer prompts to save the rules)
apt update && apt install iptables-persistent -y
# Save the current iptables rules
netfilter-persistent save
Access VM in VPC 1 #
# Access Private VM in VPC 1
ssh vm1
# Test internet connectivity
ping google.com
# Shell output:
PING google.com (142.250.179.78) 56(84) bytes of data.
64 bytes from par21s19-in-f14.1e100.net (142.250.179.78): icmp_seq=1 ttl=110 time=16.2 ms
64 bytes from par21s19-in-f14.1e100.net (142.250.179.78): icmp_seq=2 ttl=110 time=13.8 ms
# Ping private VM 2
ping 10.20.0.10
# Shell output:
PING 10.20.0.10 (10.20.0.10) 56(84) bytes of data.
Access VM in VPC 2 #
# Access Private VM in VPC 2
ssh vm2
# Test internet connectivity
ping google.com
# Shell output:
PING google.com (142.250.179.78) 56(84) bytes of data.
64 bytes from par21s19-in-f14.1e100.net (142.250.179.78): icmp_seq=1 ttl=110 time=14.6 ms
64 bytes from par21s19-in-f14.1e100.net (142.250.179.78): icmp_seq=2 ttl=110 time=13.5 ms
# Ping private VM 2
ping 10.10.0.10
# Shell output:
PING 10.10.0.10 (10.10.0.10) 56(84) bytes of data.
64 bytes from 10.10.0.10: icmp_seq=1 ttl=61 time=6.45 ms
64 bytes from 10.10.0.10: icmp_seq=2 ttl=61 time=2.46 ms