Firewall¶
Two layers of stateful packet filtering protect each VM:
- Hetzner Cloud Firewall — applied at Hetzner's network layer, before traffic reaches the VM's network interface. Managed via the Hetzner Cloud Console / API.
ufwon 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):
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/443will flip toopenautomatically 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]
}