# 📐 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)