Architecture
Six layers on one host: edge, observability, secrets, identity, data and storage, and the infrastructure automation underneath them.- Edge — Traefik reverse proxy with automatic HTTPS, and Portainer
- Observability — the LGTM stack (Loki, Grafana, Tempo, Prometheus), alert delivery and collectors
- Secrets — OpenBao, with SOPS/age as bootstrap and disaster-recovery backup
- Identity, data and storage — Keycloak, platform Postgres and MinIO
- Infrastructure — Docker Compose orchestration, Tailscale VPN for admin access, Ansible playbooks for host provisioning
Network topology
Network isolation
docker-compose.infra.yml is the sole owner of all three networks. Every other stack —
observability, vault, database and identity — declares the networks it needs as
external: true rather than creating them, which is why the edge stack must deploy first
on a fresh host. Nothing declares hill90_agent_internal; it is created and then unused.
Admin surfaces are additionally restricted to the Tailscale CGNAT range
(100.64.0.0/10) by a Traefik IP-allowlist middleware, and SSH is restricted to the same
range at the firewall.
The allowlist only became a real control on 2026-07-27
Until then the middleware also had to include the Docker bridge gateway,172.18.0.1/32, because Traefik saw that address as the client for all tailnet
traffic: Tailscale masqueraded anything carrying its firewall mark, so the true source was
rewritten before Traefik ever saw it.
An allowlist that has to admit the bridge gateway cannot distinguish on-network from
off-network traffic — anything that reached Traefik through the bridge matched it. The
list was enforcing nothing.
tailscale set --snat-subnet-routes=false removed the masquerade, so Traefik now logs
true client addresses and the allowlist is CGNAT-only. Verified on the host: the running
middleware has exactly one entry, 100.64.0.0/10, and Tailscale reports NoSNAT: true.
If you SSH in to check this, the file will confuse you. The edge stack is running a
hand-edited
middlewares.yml from the incident, on a checkout two commits behind main.
The effective allowlist matches main exactly — one entry — but the live file still
carries a comment claiming the bridge-gateway entry was restored, describing an entry that
is not there. Redeploying the edge stack will replace the file with the committed version
and the confusion goes away.hill90_edge is IPv4-only so
Docker writes no v6 DNAT rule, but docker-proxy still binds [::]:443 and re-originates
v6 connections from the bridge gateway — which used to match the allowlist. It no longer
does.
Service responsibilities
Security posture
- Public internet access is limited to ports 80 and 443, handled by Traefik. Every administrative surface is reachable only through Tailscale, with one deliberate exception: Keycloak is publicly routed, including its admin console. See Surfaces.
- SSH is not exposed publicly — the firewall’s public zone does not list it, so the only route in is over Tailscale. Root login is disabled and public-key auth is enabled. Password authentication is not disabled, despite the hardening intending it to be, and fail2ban is not installed. Both are verified on the running host and tracked as Hill90 issue 539; this page previously claimed the opposite, on the strength of the repo’s own documentation rather than a check.
- The Traefik dashboard password hash is generated at deploy time from encrypted secrets
into a gitignored
.htpasswd. - ACME state is persisted in mounted Traefik storage so renewals survive restarts.
- CI blocks destructive Docker commands from entering
scripts/*.shand.github/workflows/*.yml: a composedownwith-v,docker volume rm, anddocker system prune. Two files are exempt by name and by documented argument —scripts/local.sh, which can only reach the local daemon, and the vault reinitialize workflow, where destroying the volume is the operation.--remove-orphansis checked separately, and only indeploy.sh. - Alertmanager email delivery is proven to hand alerts to the configured mail server. This
does not prove Keycloak invitation email: the
platformrealm’s SMTP password is blank in the checked-in record, and an invitation flow remains unproven.
make health, public-versus-Tailscale
SSH, make dns-verify, ACME issuance in the Traefik logs, and the expected containers in
docker ps — lives in the security architecture doc linked below.
Source documents
These are maintained beside the code and are the authority. This page summarises them.docs/architecture/overview.md— the full architecture recorddocs/architecture/security.md— access boundaries, hardening, verification checklistdocs/architecture/certificates.md— certificate management in depthdocs/reference/deployment.md— compose files, project names, deploy safety policy