Skip to content

Network Architecture

The network is designed with isolation in mind: application servers are not publicly reachable and are only accessible through controlled paths (Cloudflare Tunnel or VPN). Public IPv4 is currently enabled for outbound-only traffic; inbound access is blocked by firewall rules.

IP Ranges

Hetzner provides a private network that connects all servers without going through the public internet. Servers on this network can communicate freely with each other using their 10.0.x.x addresses.

Range Purpose
10.0.0.0/16 Hetzner private network
10.0.1.0/24 Bastion subnet
10.0.2.0/24 Servers subnet
100.64.0.0/10 Tailscale VPN

The 100.64.0.0/10 range is CGNAT space that Tailscale uses for its mesh network. When you connect via Tailscale, you get an IP in this range (e.g., 100.64.0.6 for the bastion). This is separate from the Hetzner private network - it's an overlay that works across the internet.

Firewall Rules

Firewalls are managed in Terraform (infra/live/hetzner/firewall.tf). The principle is minimal exposure - each server only allows what it absolutely needs.

Server Allowed Inbound
headscale TCP 80, 443 from any
bastion TCP 22 from 100.64.0.0/10 only
apps All from 10.0.0.0/16 only

The headscale server must accept connections from anywhere because Tailscale clients connect to it from various networks. Port 80 is for ACME certificate validation, port 443 for the actual Headscale service.

The bastion only accepts SSH, and only from the Tailscale network. You can't SSH to it from the public internet - you must first connect to the VPN. This is the key security boundary for admin access.

The apps server has the most restrictive rules. It only accepts traffic from the Hetzner private network, meaning only the bastion can reach it. Even if an attacker found its private IP, they couldn't connect without being inside the private network.

Cloudflare Tunnel

The cloudflared daemon runs on the apps server and creates outbound connections to Cloudflare. This is a clever inversion - instead of exposing ports and having Cloudflare proxy to them, the server reaches out to Cloudflare and keeps a persistent connection open. Cloudflare then routes incoming requests through that connection.

Hostname Target Zero Trust
auth.minnova.io localhost:80 No (IdP)
grafana.minnova.io localhost:80 Yes
portainer.minnova.io localhost:80 Yes
homepage.minnova.io localhost:80 Yes
argocd.minnova.io localhost:80 Yes
forgejo.minnova.io localhost:80 No
status.minnova.io localhost:80 Yes
traefik.minnova.io localhost:80 Yes
nextcloud.minnova.io localhost:80 Yes
analytics.minnova.io localhost:80 No*

*Analytics (Umami) is not behind Zero Trust because the tracking endpoint must accept unauthenticated POST requests from external sites. See Observability for details.

All tunnel traffic lands on Traefik (port 80) which routes by hostname to the correct K3s service. The apps server has no open inbound ports to the public internet; it only needs outbound HTTPS to Cloudflare.

Headscale cannot use Cloudflare Tunnel because the Tailscale protocol requires WebSocket POST, which tunnels don't support. So Headscale runs on a separate server with a real public IP, with Traefik handling TLS termination via Let's Encrypt.

The Oracle knowledge base is delivered via Cloudflare Pages (with Access), not through this tunnel.

Traffic Flows

Web Traffic (Public Users)

sequenceDiagram
    User->>Cloudflare: HTTPS request
    Cloudflare->>cloudflared: Tunnel (outbound from apps server)
    cloudflared->>Service: localhost:port
    Service-->>User: Response via same path

Admin Access (SSH)

sequenceDiagram
    Admin->>Headscale: tailscale login --login-server https://headscale.minnova.io
    Headscale->>Authentik: OIDC authentication
    Authentik-->>Admin: Auth successful, VPN access granted
    Admin->>Bastion: SSH to 100.64.0.6
    Bastion->>Server: SSH to 10.0.x.x (internal)

Adding a New Server

When provisioning a new server:

  1. Add the server in OpenTofu (infra/live/hetzner/servers.tf). For private-only servers, set enable_ipv4 = false (and ensure you still have an outbound path for updates/tunnels).
  2. The server will be on the private network (10.0.2.0/24)
  3. SSH to configure: connect to bastion first, then jump to the new server
  4. If the server needs public web access, add a route in the Cloudflare Tunnel config
  5. Bootstrap with Ansible (K3s + ArgoCD + age key), then let ArgoCD GitOps manage runtime apps

All new servers should default to private-only unless there's a specific reason to expose them (like headscale needs to be public for VPN client connections).