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:
- Add the server in OpenTofu (
infra/live/hetzner/servers.tf). For private-only servers, setenable_ipv4 = false(and ensure you still have an outbound path for updates/tunnels). - The server will be on the private network (10.0.2.0/24)
- SSH to configure: connect to bastion first, then jump to the new server
- If the server needs public web access, add a route in the Cloudflare Tunnel config
- 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).