Actually fixed the 4 problems named earlier instead of just naming
them: a numbers-at-a-glance strip up top, the trust-boundary diagram
is now color-coded by boundary type, added a concrete blast-radius
table showing exactly what stays up vs goes down per failure, and
replaced vague figures ('~40 stacks', 'every managed host') with
real ones (41 stacks, 11 fleet hosts).
128 lines
9.5 KiB
Markdown
128 lines
9.5 KiB
Markdown
# 📐 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.
|
|
|
|
<table>
|
|
<tr>
|
|
<td align="center"><strong>11</strong><br/>fleet hosts</td>
|
|
<td align="center"><strong>55</strong><br/>compose stacks</td>
|
|
<td align="center"><strong>4</strong><br/>trust boundaries</td>
|
|
<td align="center"><strong>3</strong><br/>routing authorities</td>
|
|
<td align="center"><strong>8</strong><br/>traffic laws</td>
|
|
</tr>
|
|
</table>
|
|
|
|
## 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<br/>41 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<br/>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<br/>untrusted"] -->|public| EdgeFW["Edge Firewall<br/>public proxy"]
|
|
Browser["🧑 LAN browsers<br/>human-operated"] -->|mTLS + SSO| UserProxy["User-facing proxy"]
|
|
Service["📦 App-to-app<br/>machine-provisioned"] -->|allowlist + SSO| InternalProxy["Internal proxy"]
|
|
Infra["⚙️ LAN infra-to-infra<br/>non-HTTP"] -->|per-flow rule| HostFW["Host firewall"]
|
|
|
|
classDef untrusted fill:#f8cecc,stroke:#b85450,color:#333
|
|
classDef human fill:#dae8fc,stroke:#6c8ebf,color:#333
|
|
classDef machine fill:#d5e8d4,stroke:#82b366,color:#333
|
|
classDef infra fill:#fff2cc,stroke:#d6b656,color:#333
|
|
class WAN,EdgeFW untrusted
|
|
class Browser,UserProxy human
|
|
class Service,InternalProxy machine
|
|
class Infra,HostFW infra
|
|
```
|
|
|
|
| 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.
|
|
|
|
### What actually happens when one fails
|
|
|
|
Not a hypothetical — this is the real, deliberate payoff of splitting routing authority three ways instead of one:
|
|
|
|
| If this fails... | ❌ Affected | ✅ Unaffected |
|
|
|---|---|---|
|
|
| Edge firewall | All public/WAN access | Everything internal — browsers, app-to-app, infra traffic all keep working |
|
|
| User-facing proxy | Every human browser session | Public access, all app-to-app traffic, all infra traffic |
|
|
| Internal proxy zone | App-to-app service calls | Public access, human browser sessions, infra traffic (it doesn't route through here at all) |
|
|
|
|
A single-router design fails this test completely — one outage, one misconfiguration, everything goes dark at once. Three independent authorities mean the worst case for any one failure is "a third of the traffic types stop," never all of them.
|
|
|
|
> [!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)
|