Skip to main content

Hetzner Cloud Private Network with VM based NAT Gateway: Deploy Hetzner Cloud Server without Public IP, Terraform Configuration

1285 words·
Hetzner Cloud NAT Gateway Jump Host iptables Terraform
Table of Contents

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