Skip to content

Firewall

Two layers of stateful packet filtering protect each VM:

  1. Hetzner Cloud Firewall — applied at Hetzner's network layer, before traffic reaches the VM's network interface. Managed via the Hetzner Cloud Console / API.
  2. ufw on the host — applied at the kernel layer on the VM itself. Same allow-list as the cloud firewall. Last line of defence if the cloud firewall is misconfigured or detached.

Both layers are intentionally default-deny inbound with the same short allow-list. Outbound is unrestricted (see rationale in index.md).

grintest-dev-public — Hetzner Cloud Firewall for dev

Applied to: grintest-dev-01.

# Hetzner Cloud Firewall: grintest-dev-public
# Last updated: 2026-06-01 (GRI-123)

name: grintest-dev-public
applied_to:
  - grintest-dev-01

inbound_rules:
  - description: "SSH (open to world; mitigated by key-only auth + fail2ban; revisit when Tailscale lands)"
    direction: in
    protocol: tcp
    port: "22"
    source_ips:
      - 0.0.0.0/0
      - ::/0

  - description: "HTTP  reverse-proxy entry point (Coolify will manage TLS termination)"
    direction: in
    protocol: tcp
    port: "80"
    source_ips:
      - 0.0.0.0/0
      - ::/0

  - description: "HTTPS  reverse-proxy entry point (Coolify will manage TLS termination)"
    direction: in
    protocol: tcp
    port: "443"
    source_ips:
      - 0.0.0.0/0
      - ::/0

  - description: "ICMP  allow ping for debugging"
    direction: in
    protocol: icmp
    source_ips:
      - 0.0.0.0/0
      - ::/0

# Outbound: not declared → Hetzner default (allow all). Intentional for dev.

Host ufw baseline

The host runs ufw with the same allow-list as the cloud firewall, plus fail2ban on the SSH jail. Both are part of the standard host baseline applied to every VM — not duplicated here.

When adding a new public port, the change must land in both layers: the Hetzner Cloud Firewall (this page) and the host ufw (host baseline).

Verification

Run from any external host (not from inside the VM):

nmap -T4 -p 22,80,443,5432,6379,9092 dev.grintest.pl

Expected:

22/tcp   open                ← sshd listening
80/tcp   closed              ← firewall passes; no reverse proxy yet (Coolify)
443/tcp  closed              ← firewall passes; no reverse proxy yet (Coolify)
5432/tcp filtered            ← cloud firewall drops the packet
6379/tcp filtered            ← cloud firewall drops the packet
9092/tcp filtered            ← cloud firewall drops the packet

State meanings:

  • open — firewall passes, service is listening.
  • closed — firewall passes, no service listening (kernel sends RST). This is the goldilocks state for "configured but not yet used"; 80/443 will flip to open automatically when Coolify installs a reverse proxy. No firewall changes needed.
  • filtered — firewall drops the packet, no response. This is what we want for every internal-service port.

Deviations from the original ticket

GRI-123 originally specified SSH allowlisted to Coolify SaaS + home/work static IPs. We opened SSH (22) to 0.0.0.0/0 instead. Trade-off:

  • Solo developer, mobile and travel networks → static-IP allowlisting is painful in practice (rotating mobile IPs, hotel Wi-Fi, etc.).
  • Mitigations in place: key-only auth, password auth disabled, fail2ban on the SSH jail, strong key passphrase, automatic security updates.

The follow-up plan is to install Tailscale (mesh VPN, free for personal use) on the laptop + VM and then close port 22 entirely at the cloud firewall — see the Tailscale Linear ticket.

Terraform shape (forward-looking)

resource "hcloud_firewall" "grintest_dev_public" {
  name = "grintest-dev-public"

  rule { direction = "in" protocol = "tcp"  port = "22"  source_ips = ["0.0.0.0/0", "::/0"] description = "SSH" }
  rule { direction = "in" protocol = "tcp"  port = "80"  source_ips = ["0.0.0.0/0", "::/0"] description = "HTTP"  }
  rule { direction = "in" protocol = "tcp"  port = "443" source_ips = ["0.0.0.0/0", "::/0"] description = "HTTPS" }
  rule { direction = "in" protocol = "icmp"             source_ips = ["0.0.0.0/0", "::/0"] description = "ICMP"  }
}

resource "hcloud_firewall_attachment" "dev" {
  firewall_id = hcloud_firewall.grintest_dev_public.id
  server_ids  = [hcloud_server.dev.id]
}