# 📐 Architecture Overview
> [!NOTE]
> Most homelabs have one trust boundary: "inside the LAN = trusted." This one has four, each calibrated to a different actual risk level rather than applying maximum security everywhere or none anywhere — that calibration is the hardest and most interesting decision in the whole architecture, and the rest of this doc builds out from it.
## Dependency graph
```mermaid
flowchart LR
Internet(["Internet"]) --> Edge["Edge Firewall"]
Edge --> LAN["LAN"]
LAN --> DNS["Internal DNS"]
LAN --> Vault["Vault"]
DNS --> Vault
Vault --> Apps["App Host
~40 stacks"]
Vault --> Automation["Automation Host"]
LAN --> FileServer["File Server"]
DNS --> FileServer
LAN --> Automation
Apps --> InternalProxy["Internal Proxy Zone"]
Apps --> SSO["SSO"]
Automation --> GitHost["Git Hosting"]
Vault --> EdgeProxy["User-Facing Proxy
mTLS"]
DNS --> EdgeProxy
Apps -.proxied.-> EdgeProxy
SSO -.auth.-> EdgeProxy
Apps -.-> Monitoring["Monitoring"]
Automation -.-> Monitoring
FileServer -.-> Monitoring
Vault -.-> Monitoring
DNS -.-> Monitoring
EdgeProxy -.-> Monitoring
Apps -.-> SecMon["Security Monitoring"]
Automation -.-> SecMon
FileServer -.-> SecMon
Vault -.-> SecMon
DNS -.-> SecMon
EdgeProxy -.-> SecMon
```
> [!TIP]
> Solid arrows are real dependencies — the target has to be reachable and healthy for the source to function. Dotted arrows are asynchronous reporting, not a blocking dependency.
## The hardest decision: how many trust boundaries is enough?
The easy answers are both wrong. **One boundary** ("you're on the LAN, you're trusted") is what most self-hosted setups actually run, and it means a single compromised container can reach everything else with no further resistance. **Maximum boundaries everywhere** (mTLS on every single connection, no exceptions) sounds safer but isn't free — every hop gets slower, every service needs its own client certificate lifecycle, and the operational burden of *that* is exactly the kind of complexity that gets quietly disabled under deadline pressure, which is worse than never having built it.
The actual answer here is **four boundaries, each doing a different job**:
```mermaid
flowchart LR
WAN["WAN"] -->|public| EdgeFW["Edge Firewall
public proxy"]
Browser["LAN browsers"] -->|mTLS + SSO| UserProxy["User-facing proxy"]
Service["App-to-app"] -->|allowlist + SSO| InternalProxy["Internal proxy"]
Infra["LAN infra-to-infra"] -->|per-flow rule| HostFW["Host firewall"]
```
| Boundary | Who's on the other end | Control | Why this control, not another |
|---|---|---|---|
| WAN → edge | Anyone on the internet | Public reverse proxy, firewall-filtered | The only boundary facing genuinely untrusted traffic — gets the strictest, most isolated treatment |
| Browsers → user-facing proxy | A human, on a device that could be compromised, phished, or simply lost | Mutual TLS (client cert required) + SSO | The highest-value target for credential theft — a stolen password alone isn't enough without the client cert too |
| App-to-app | A container that's already inside the network, already provisioned by the same pipeline as everything else | IP-allowlist + SSO fallback, no mTLS | Full mTLS here would mean managing certificate lifecycle for every internal service-to-service call — real overhead for traffic that's already constrained to known, provisioned sources. The risk profile doesn't justify the same control as human-operated browsers. |
| LAN infra-to-infra | Non-HTTP traffic — metrics, logs, NTP, agent reporting | Explicit per-flow firewall rule, no proxy at all | Can't go through a reverse proxy — it isn't HTTP. The firewall itself is the only layer that *can* enforce this one. |
**Three separate routing authorities enforce these** — the edge firewall, the user-facing proxy, and the internal proxy zone — rather than one central router handling everything. That's a deliberate rejection of the simpler single-router design: one router is one thing to hardening-focus on and one thing that takes everything down if it's compromised or misconfigured. Three means a mistake in one router's config, or a full outage of one of them, has a blast radius bounded to the traffic it alone handles — the failure-behavior tables throughout this doc all trace back to this one decision.
> [!IMPORTANT]
> **This wasn't the starting design — it was built up over two real, deliberate changes.** The public-facing reverse proxy used to run as its own dedicated VM; it was consolidated directly onto the edge firewall as a package instead, removing an entire host from the public-ingress path without losing any functionality. And the app-to-app boundary used to not exist at all — internal service traffic ran over a flat, unauthenticated mesh network with no trust boundary. It was replaced by the two-zone model now described in [`docs/dns-naming.md`](dns-naming.md). Both changes added real operational complexity in exchange for a smaller blast radius — accepted deliberately, not stumbled into, and the kind of decision that only looks obvious in hindsight.
## The other six traffic laws
Each of these gets its own full mechanism doc elsewhere in this repo — this table is the map, not the territory:
| # | Law | Failure behavior |
|---|---|---|
| 3. Secrets | Applications never talk to Vault directly — one hop through a local agent only. See [`docs/secrets-and-pki.md`](secrets-and-pki.md). | Vault down → secret renewal stops, services run until their current token expires. No immediate disruption. |
| 4. DNS | Which internal zone a hostname resolves in determines which trust boundary (above) a request enters. See [`docs/dns-naming.md`](dns-naming.md). | DNS down → app-to-app resolution fails; direct-IP fallback exists but silently bypasses the SSO gate. |
| 5. Observability | Logs and metrics push asynchronously — never inline with application traffic, full stop. See [`docs/observability.md`](observability.md) and [`docs/security-monitoring.md`](security-monitoring.md). | Monitoring down → collection stops; every application service keeps running, completely unaffected. |
| 6. Notifications | Two independent branches (message-queue and HTTP webhook) converge on one formatter; neither is part of any service's runtime dependency chain. See [`docs/notifications.md`](notifications.md). | A dropped notification is lost, not retried into a backlog that could pressure the sender. |
| 7. CI/CD | Build/deploy traffic never sits inline with runtime; schedule-triggered, not push-triggered. See [`docs/registry-and-build.md`](registry-and-build.md). | A failed deploy doesn't affect already-running services — enforced by the redeploy step's own rollback logic. |
| 8. Management | Administrative access is its own separate plane — one dedicated identity, non-standard ports, explicit per-host firewall rules from one source only. | Management access down → administration unavailable; every running service is completely unaffected. |
The pattern repeats across all eight laws, not just the trust-boundary story above: **control-plane failure never takes down the data plane.** Every "failure behavior" row above ends the same way — the thing that broke stops working, and everything else keeps running regardless. That's not a coincidence; it's the same design principle applied consistently everywhere, not just to network trust.
## What this map deliberately excludes
Not a firewall rule listing, not a port inventory, not a service configuration reference — those live in [`docs/firewall-and-network.md`](firewall-and-network.md) and the docs linked in the table above. This is strictly the stable, directional shape of how trust and traffic actually move — meant to still be true even after the specific tools implementing it change again, the way it already has twice.
---
Related: [`docs/firewall-and-network.md`](firewall-and-network.md), [`docs/dns-naming.md`](dns-naming.md), [`docs/secrets-and-pki.md`](secrets-and-pki.md), [`docs/notifications.md`](notifications.md), [`docs/registry-and-build.md`](registry-and-build.md)