docs: publish 2026-04-26
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
# homelab
|
||||||
|
|
||||||
|
My homelab. Self-hosted, segmented, runs 24/7 on a single Proxmox host. Domain: `lerkolabs.com`.
|
||||||
|
|
||||||
|
## Why I built this
|
||||||
|
|
||||||
|
Started as a way to actually learn the stuff I was reading about for CompTIA — VLANs, firewall rules, segmentation. The plan was "I'll buy a small router, tag a few VLANs, run a couple of services." That was a few years ago. Now it's the platform I run my password manager on, my photos, my notes, my budget, and a Discord music bot for fun.
|
||||||
|
|
||||||
|
The reason it's still around is that documenting it forces me to think about it like a real system. Anything I haven't documented usually breaks at the worst time, so I started writing things down. This repo is what came out of that.
|
||||||
|
|
||||||
|
## What's running
|
||||||
|
|
||||||
|
| Layer | Tool |
|
||||||
|
|---|---|
|
||||||
|
| Hypervisor | Proxmox VE |
|
||||||
|
| Firewall | pfSense (low-power x86) |
|
||||||
|
| Switching | TP-Link Omada (managed VLANs) |
|
||||||
|
| Reverse proxy | Caddy (Cloudflare DNS-01) |
|
||||||
|
| Identity | Authentik (OIDC + forward auth) |
|
||||||
|
| DNS | Pi-hole → Unbound → Cloudflare |
|
||||||
|
| Remote access | WireGuard |
|
||||||
|
| Monitoring | Victoria Metrics + Grafana + Beszel |
|
||||||
|
| Backups | Proxmox Backup Server |
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
One Proxmox host. One firewall. One person operating it. About ten LXCs and a couple of VMs running roughly twenty services across seven VLANs.
|
||||||
|
|
||||||
|
There's no HA. I made that call on purpose — building real redundancy at this scale would mean buying hardware I don't need to keep idle, and the more useful skill to practice is "have a tested rebuild path." If something dies, I restore from PBS, follow the rebuild runbook, and I'm back in a few hours. That's good enough for a homelab and honestly closer to how a lot of small teams actually operate.
|
||||||
|
|
||||||
|
## How I think about it
|
||||||
|
|
||||||
|
- **VLANs by trust tier, not by purpose.** Management is its own thing because compromising it would be the worst outcome — not because it's "the network stuff." IoT is its own thing because I don't trust cloud-managed appliances, not because they're "smart home stuff."
|
||||||
|
- **No anonymous access to anything internal.** Authentik gates everything. If an app supports OIDC I use that; if it doesn't, Caddy does forward auth.
|
||||||
|
- **Public surface stays small.** Only a handful of services are reachable from the internet, and they all live behind a DMZ-isolated reverse proxy that's locked down at the firewall level — not just trusted to behave.
|
||||||
|
- **Admin is VPN-only.** No firewall GUI, hypervisor, backup server, switch, or AP is reachable from the internet. Ever. WireGuard first, everything else after.
|
||||||
|
- **Edge does TLS, internal hops are HTTP.** Less to maintain, and segmentation handles confidentiality on internal links.
|
||||||
|
- **Configs and secrets are not in this repo.** Secrets in a password manager; configs and version-state tracked in a private repo.
|
||||||
|
|
||||||
|
## Documented here
|
||||||
|
|
||||||
|
| Doc | About |
|
||||||
|
|---|---|
|
||||||
|
| [Services](docs/SERVICES.md) | What's deployed, grouped by what it does |
|
||||||
|
| [Network](docs/NETWORK.md) | Segmentation, firewall posture, DNS |
|
||||||
|
| [Security](docs/SECURITY.md) | Layered controls, threat model, limitations |
|
||||||
|
|
||||||
|
The operational stuff — exact IP plan, hardware inventory, ADRs, rebuild runbook, retention policies — is in a private repo. That separation is on purpose: this repo is for the reasoning; the private one is for actually running the thing.
|
||||||
|
|
||||||
|
## What I learned along the way
|
||||||
|
|
||||||
|
- Documenting *as I go* beats documenting *after*. I tried the second way first and the docs were lying within a month. Now I treat anything without a doc update as unfinished work.
|
||||||
|
- Defaults are decisions, even when you don't notice them. Caddy issuing per-hostname certs by default means every internal hostname I've ever used shows up in Certificate Transparency logs forever. That kind of thing gets caught only if you write down the *why* of each setup choice and revisit it.
|
||||||
|
- Segmentation pays for itself the first time something gets popped. I haven't been compromised, but designing as if I will be has made every other call easier.
|
||||||
|
- "It just works" is usually a sign I haven't looked hard enough yet. Most of the interesting stuff in this repo started with me trying to explain something I'd built and realizing I couldn't.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Stable. Reviewed quarterly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This is part of a portfolio. The private repo with the operational details is available on request.*
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Authentication Flow
|
||||||
|
|
||||||
|
Forward auth path for an internal service that doesn't speak OIDC natively. OIDC-native services skip the Caddy hop and go straight to Authentik for the auth handshake — same trust boundary, fewer round trips.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant C as Caddy<br/>(reverse proxy)
|
||||||
|
participant A as Authentik<br/>(IdP)
|
||||||
|
participant S as Internal service
|
||||||
|
|
||||||
|
U->>C: HTTPS request
|
||||||
|
C->>A: Forward auth check
|
||||||
|
A-->>C: 401 (no session)
|
||||||
|
C-->>U: 302 → auth.lerkolabs.com
|
||||||
|
|
||||||
|
U->>A: Login (OIDC or password)
|
||||||
|
A-->>U: Set session cookie
|
||||||
|
|
||||||
|
U->>C: HTTPS request + cookie
|
||||||
|
C->>A: Forward auth check
|
||||||
|
A-->>C: 200 OK + identity headers
|
||||||
|
C->>S: Proxy request<br/>(plain HTTP, internal hop)
|
||||||
|
S-->>U: Response
|
||||||
|
```
|
||||||
|
|
||||||
|
## How this ended up here
|
||||||
|
|
||||||
|
Authentik was originally only standing up because Outline needed an IdP to function. I figured I'd run it for that one app and forget about it. Then I started noticing how many other apps had OIDC support sitting right there, and integrating each new one was cheap once the IdP was already in place. After a few months, "every internal service goes through SSO" had become the default without me ever sitting down to decide it.
|
||||||
|
|
||||||
|
The forward-auth path in this diagram is the catch-all for apps that don't speak OIDC. Caddy intercepts the request, asks Authentik whether the user is logged in, and either proxies through or bounces them to the login page. Less elegant than native OIDC, but it means the "log in with the app's local account" bypass simply doesn't exist anywhere. Every door uses the same key.
|
||||||
|
|
||||||
|
The Discord music bot is the one case where this same flow is reachable from the public internet. That started because I wanted my friends to be able to use the bot, which meant the dashboard had to be hittable from outside the LAN. Authentik in the DMZ Caddy gates the request; the policy only lets through specific Discord user IDs. Friends get in, randoms don't, and the same auth machinery I was already running handles it.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Edge terminates TLS; internal hops are HTTP.** Trust on internal hops is established by segmentation and identity, not by re-encrypting every jump.
|
||||||
|
- **Identity headers from Authentik** are passed to the upstream service so it can attribute requests to a user without implementing its own auth.
|
||||||
|
- **No anonymous access path.** If Authentik is down, internal services are unreachable. Accepted tradeoff over the alternative — a fallback "skip auth" mode would inevitably get used and would inevitably be the thing that got abused.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# DNS Resolution
|
||||||
|
|
||||||
|
Two flows, one resolver chain. Splitting them apart because the interesting part of the design is what *doesn't* go to the upstream.
|
||||||
|
|
||||||
|
## External resolution
|
||||||
|
|
||||||
|
What happens when a client asks for a public domain.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
CLIENT[Client<br/>most VLANs] --> PIHOLE[Pi-hole<br/>filtering + cache]
|
||||||
|
PIHOLE -->|miss| UNBOUND[Unbound on firewall<br/>recursive + DNSSEC]
|
||||||
|
UNBOUND --> UPSTREAM[Cloudflare<br/>fallback only]
|
||||||
|
|
||||||
|
PIHOLE -.->|blocked| BLOCKED[Ad/tracker<br/>domains]
|
||||||
|
|
||||||
|
classDef client fill:#1f2f3a,stroke:#3a6b8b,color:#d0e0f0
|
||||||
|
classDef resolver fill:#1f3a2f,stroke:#3a8b6b,color:#d0f0e0
|
||||||
|
classDef upstream fill:#3a2f1f,stroke:#8b6b3a,color:#f0e0d0
|
||||||
|
classDef blocked fill:#3a1f1f,stroke:#8b3a3a,color:#f0d0d0
|
||||||
|
|
||||||
|
class CLIENT client
|
||||||
|
class PIHOLE,UNBOUND resolver
|
||||||
|
class UPSTREAM upstream
|
||||||
|
class BLOCKED blocked
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local hostname resolution (split-horizon)
|
||||||
|
|
||||||
|
What happens when a client asks for an internal hostname. The query never leaves the LAN — Pi-hole answers from its local A records, and the client connects to the internal reverse proxy directly.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
CLIENT[Client] -->|asks for<br/>app.lerkolabs.com| PIHOLE[Pi-hole<br/>local A records]
|
||||||
|
PIHOLE -->|returns<br/>internal IP| CLIENT
|
||||||
|
CLIENT -->|HTTPS<br/>valid public cert| CADDY[Internal Caddy<br/>reverse proxy]
|
||||||
|
CADDY --> SVC[Internal service]
|
||||||
|
|
||||||
|
classDef client fill:#1f2f3a,stroke:#3a6b8b,color:#d0e0f0
|
||||||
|
classDef resolver fill:#1f3a2f,stroke:#3a8b6b,color:#d0f0e0
|
||||||
|
classDef edge fill:#2f1f3a,stroke:#6b3a8b,color:#e0d0f0
|
||||||
|
|
||||||
|
class CLIENT client
|
||||||
|
class PIHOLE resolver
|
||||||
|
class CADDY,SVC edge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this design
|
||||||
|
|
||||||
|
A few things are doing more work here than they look.
|
||||||
|
|
||||||
|
**Pi-hole is the only authoritative source for internal names.** One source of truth for hostname → IP, one place to update when something moves. It's also a documented SPOF — if it dies, internal hostnames stop resolving. I considered mirroring the records into Unbound on the firewall as a fallback and decided not to. I'd rather know Pi-hole is unhealthy than paper over it with a fallback that hides the problem.
|
||||||
|
|
||||||
|
**Internal services get valid public certs without ever being exposed to the internet.** Cloudflare DNS-01 ACME proves I control the domain via a TXT record; the cert never requires a publicly-reachable HTTP-01 challenge. Combined with split-horizon DNS, a VPN or LAN client browsing to `app.lerkolabs.com` gets a real cert chain on a connection that never leaves the network. The cert proves identity; segmentation handles confidentiality.
|
||||||
|
|
||||||
|
**Bootstrap exception.** The host running Pi-hole has to resolve through the firewall directly, not through itself, or nothing comes up at boot. Took a power outage to learn that one cleanly.
|
||||||
|
|
||||||
|
**WFH and Management tiers don't use Pi-hole.** Different reasons, both deliberate — see private repo for detail. The short version: the WFH laptop shouldn't see the local hostname inventory, and Management hosts can't depend on Pi-hole being up.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Network Topology
|
||||||
|
|
||||||
|
Two views of the same network. The trust-tier diagram is how I *reason* about it. The physical-flow one is for when someone asks "but where does it actually plug in."
|
||||||
|
|
||||||
|
## Trust tiers and policy
|
||||||
|
|
||||||
|
Seven VLANs grouped by how much I trust what's on them. Edges are allowed inter-tier flows; everything else is default-deny.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph UNTRUSTED["Untrusted — internet only, no internal access"]
|
||||||
|
GUEST[Guest WiFi]
|
||||||
|
IOT[IoT]
|
||||||
|
WFH[Work-from-home]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph PUBLIC["Public-facing"]
|
||||||
|
DMZ[DMZ<br/>reverse proxy + public services]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph TRUSTED["Trusted"]
|
||||||
|
LAN[LAN<br/>personal devices]
|
||||||
|
INT[Internal services<br/>app stack]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph MGMT["Management — VPN-only"]
|
||||||
|
ADMIN[Hypervisor, firewall,<br/>backup, switches, APs]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph REMOTE["Remote"]
|
||||||
|
VPN[WireGuard clients]
|
||||||
|
end
|
||||||
|
|
||||||
|
INTERNET((Internet))
|
||||||
|
|
||||||
|
UNTRUSTED -->|outbound only| INTERNET
|
||||||
|
INTERNET -->|HTTP/HTTPS<br/>tight allowlist| DMZ
|
||||||
|
INTERNET -->|WireGuard<br/>UDP| VPN
|
||||||
|
|
||||||
|
DMZ -.->|narrow allowlist<br/>firewall-enforced| INT
|
||||||
|
LAN -->|consume services| INT
|
||||||
|
VPN -->|LAN-equivalent +<br/>admin access| INT
|
||||||
|
VPN --> ADMIN
|
||||||
|
|
||||||
|
classDef untrusted fill:#3a1f1f,stroke:#8b3a3a,color:#f0d0d0
|
||||||
|
classDef public fill:#3a2f1f,stroke:#8b6b3a,color:#f0e0d0
|
||||||
|
classDef trusted fill:#1f3a2f,stroke:#3a8b6b,color:#d0f0e0
|
||||||
|
classDef mgmt fill:#1f2f3a,stroke:#3a6b8b,color:#d0e0f0
|
||||||
|
classDef remote fill:#2f1f3a,stroke:#6b3a8b,color:#e0d0f0
|
||||||
|
|
||||||
|
class GUEST,IOT,WFH untrusted
|
||||||
|
class DMZ public
|
||||||
|
class LAN,INT trusted
|
||||||
|
class ADMIN mgmt
|
||||||
|
class VPN remote
|
||||||
|
```
|
||||||
|
|
||||||
|
## Physical flow
|
||||||
|
|
||||||
|
What plugs into what. Tier labels, not addresses.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
ISP[ISP] --> GW[Carrier gateway<br/>passthrough mode]
|
||||||
|
GW --> FW[pfSense firewall]
|
||||||
|
FW --> SW[Managed switch<br/>VLAN-aware]
|
||||||
|
|
||||||
|
SW --> T_MGMT[MGMT tier]
|
||||||
|
SW --> T_INT[Internal services tier]
|
||||||
|
SW --> T_LAN[LAN tier]
|
||||||
|
SW --> T_WFH[WFH tier]
|
||||||
|
SW --> T_IOT[IoT tier]
|
||||||
|
SW --> T_GUEST[Guest tier]
|
||||||
|
SW --> T_DMZ[DMZ tier]
|
||||||
|
|
||||||
|
FW -.->|VPN concentrator| VPN[WireGuard]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why two reverse proxies, not one
|
||||||
|
|
||||||
|
The DMZ-to-internal arrow above does a lot of work, so worth being explicit. There are two Caddy instances:
|
||||||
|
|
||||||
|
- One in the DMZ, internet-facing, fronting a deliberately small set of public services.
|
||||||
|
- One in the internal services tier, LAN/VPN only, fronting everything else.
|
||||||
|
|
||||||
|
The first version of this was a single Caddy cloned into the DMZ doing both jobs. It "worked," in the sense that nothing was on fire — but every internal admin surface was technically internet-reachable, gated only by app-level auth. Once I drew it out I realized I'd built exactly the thing the DMZ was supposed to prevent. Splitting them was the fix: the DMZ Caddy can only see the small handful of backends it's allowed to reach, the firewall enforces that independently of the proxy config, and VPN clients hit the internal Caddy directly without ever touching the DMZ.
|
||||||
|
|
||||||
|
This is the layered-controls principle from `SECURITY.md` made concrete. Both the proxy and the firewall enforce the same allowlist. Misconfiguring the proxy is way easier than misconfiguring the firewall, so they back each other up.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Inter-tier policy is enforced at the firewall. Intra-tier traffic between hosts on the same bridge does not — see `NETWORK.md` for why that matters when reasoning about blast radius.
|
||||||
|
- Subnets, VLAN IDs, hardware models, and ISP details live in the private repo. The trust tiers are the part worth publishing; the IP plan isn't.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Network
|
||||||
|
|
||||||
|
How I think about segmentation and why the policy looks the way it does. Specific subnets, VLAN IDs, IP plans, and firewall rule listings live in the private repo.
|
||||||
|
|
||||||
|
## Why segmentation matters here
|
||||||
|
|
||||||
|
A homelab pulls together an unusually wide trust spread on one piece of hardware: cloud-managed IoT devices that phone home constantly, a work laptop that touches an employer network, a guest WiFi that strangers join, internal services holding sensitive data, and admin surfaces that should never be exposed. Treating all of that as one flat network treats it like it has the same trust level. It doesn't.
|
||||||
|
|
||||||
|
The model here is **trust-tier VLANs** with explicit policy between them. Every tier has a documented purpose and a defined inbound/outbound posture.
|
||||||
|
|
||||||
|
## Trust tiers
|
||||||
|
|
||||||
|
Seven VLANs, organized roughly by how much I trust what's on them:
|
||||||
|
|
||||||
|
| Tier | What's on it | Posture |
|
||||||
|
|---|---|---|
|
||||||
|
| **Management** | Hypervisor, firewall, backup server, network controllers | Most trusted. Reachable only via VPN. Doesn't initiate outbound unless it has to. |
|
||||||
|
| **Internal services** | LXCs and VMs running the internal app stack | Trusted. Serves clients in adjacent tiers per policy. |
|
||||||
|
| **LAN** | Personal devices on home WiFi/Ethernet | Trusted. Consumes internal services. |
|
||||||
|
| **Work-from-home** | Employer-owned laptop | Untrusted lateral. Internet only — blocked from everything else, including internal DNS. |
|
||||||
|
| **IoT** | Smart devices, cloud-managed appliances | Untrusted. Internet only. Isolated from everything internal. |
|
||||||
|
| **Guest** | Visitor WiFi | Untrusted. Internet only. |
|
||||||
|
| **DMZ** | Internet-facing services | Treated as compromised by default. Locked down on outbound; inbound to internal is a tight allowlist. |
|
||||||
|
| **VPN (WireGuard)** | Authenticated remote clients | Same posture as LAN, plus admin-tier visibility. |
|
||||||
|
|
||||||
|
## Policy posture
|
||||||
|
|
||||||
|
- **Default deny inter-VLAN.** Every cross-tier flow is an explicit allow rule with a reason written next to it.
|
||||||
|
- **WFH and IoT are jailed.** They reach the internet and nothing else internal — not even DNS for the local hostnames. This is the most important rule in the firewall.
|
||||||
|
- **Management is the smallest possible tier.** Only what *runs* the lab lives there. No user-facing services. No outbound internet from anything that doesn't strictly need it.
|
||||||
|
- **DMZ is one-way.** Public services live there. They can't initiate connections inward except through a tight, firewall-enforced allowlist by source IP and destination port. The reverse proxy in the DMZ is *configured* to respect that, and the firewall is *also* configured to enforce it. Two layers, on purpose — misconfiguring the proxy is way easier than misconfiguring the firewall.
|
||||||
|
- **Admin surfaces are VPN-only.** Hypervisor, firewall, backup server, switches, APs — none of them are reachable from the internet. WireGuard first or it doesn't happen.
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
Three layers, each doing one job:
|
||||||
|
|
||||||
|
1. **Pi-hole** — first hop for clients on most VLANs. Filters ad/tracker domains and holds the local A records that map internal hostnames to internal IPs. Not used by management hosts (see below) or by the WFH VLAN.
|
||||||
|
2. **Unbound on the firewall** — Pi-hole's upstream. Recursive resolver, validates DNSSEC.
|
||||||
|
3. **Cloudflare** — Unbound's eventual upstream when needed.
|
||||||
|
|
||||||
|
**Bootstrap exception:** the hypervisor itself (which is the box Pi-hole runs on) is statically pointed at the firewall's resolver, not Pi-hole. Otherwise there's a circular dependency at boot — the hypervisor needs DNS to come up, and Pi-hole is one of the things the hypervisor brings up.
|
||||||
|
|
||||||
|
**Known SPOF:** Pi-hole is the only thing resolving internal hostnames. If it dies, internal hostnames stop resolving until it's back. I thought about mirroring the records into Unbound on pfSense and decided not to — I'd rather know if Pi-hole is unhealthy than paper over it. Documented as a known limitation in the private repo.
|
||||||
|
|
||||||
|
## Internet exposure
|
||||||
|
|
||||||
|
Three ports forwarded from WAN to internal:
|
||||||
|
|
||||||
|
- **HTTP / HTTPS** — to the DMZ reverse proxy. Serves the small public service set.
|
||||||
|
- **WireGuard** — to the firewall. The only remote admin path.
|
||||||
|
|
||||||
|
Everything else is closed. I verify this from outside the network on a regular basis — the only way to actually know what's exposed is to scan from somewhere that isn't the LAN.
|
||||||
|
|
||||||
|
## IPv6
|
||||||
|
|
||||||
|
Disabled at the carrier-provided gateway. The lab is IPv4-only by design — fewer surfaces, simpler firewall reasoning, no AAAA leakage. I'll revisit this if I have a reason to; today I don't.
|
||||||
|
|
||||||
|
## Things that are easy to overlook
|
||||||
|
|
||||||
|
A couple of things worth being explicit about, because they bit me at some point:
|
||||||
|
|
||||||
|
- **Intra-VLAN traffic between LXCs on the same Proxmox bridge doesn't traverse the firewall.** Isolation is enforced *per-VLAN*, not *per-LXC*. Two LXCs sharing a tier can talk to each other directly. Useful to remember when you're reasoning about blast radius — the firewall doesn't see anything that doesn't cross a VLAN boundary.
|
||||||
|
- **Certificate Transparency.** Caddy uses Cloudflare DNS-01 for cert issuance, which is great because services don't have to be exposed to the internet to get a cert. But every cert that gets issued lands in CT logs forever, and per-hostname certs basically publish the internal hostname inventory to anyone who runs a CT search on the domain. A wildcard cert would limit CT exposure to `*.lerkolabs.com` and the apex; it's on my list as a future change, with the tradeoff being that wildcard compromise is worse than per-host.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Posture and practices. No enumerated weaknesses or specific control parameters here — those live in the private repo where they belong.
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
Operating assumption: this is a one-person homelab on a residential connection, running a mix of services I rely on (password manager, calendar, photos) and a few that are reachable from the internet (portfolio, self-hosted Git). The realistic threat is opportunistic — bots scanning my public IP, automated exploitation of known CVEs in exposed services, credential stuffing against any public login.
|
||||||
|
|
||||||
|
A targeted, well-resourced adversary is out of scope. The defenses are designed to make the cost of opportunistic attacks high enough that automation gives up and moves on.
|
||||||
|
|
||||||
|
## Layered controls
|
||||||
|
|
||||||
|
The defenses are intentionally redundant. Any single layer failing should leave the next one intact.
|
||||||
|
|
||||||
|
1. **Network segmentation.** Trust-tier VLANs with default-deny inter-tier policy. A compromise on one VLAN doesn't get lateral movement to another without crossing an explicit firewall rule.
|
||||||
|
2. **Public surface stays small.** Only a handful of services are reachable from the internet, all proxied through a DMZ-isolated host with a tight firewall-enforced allowlist into internal.
|
||||||
|
3. **TLS at the edge, properly.** ACME-automated certs via Cloudflare DNS-01. Public hostnames carry HSTS preload-eligible headers.
|
||||||
|
4. **Identity in front of everything internal.** Authentik handles SSO. OIDC where the app supports it; reverse-proxy forward auth where it doesn't. There's no "log in with the app's local account" bypass.
|
||||||
|
5. **Admin is VPN-only.** Hypervisor, firewall, backup server, switches, APs — none of them reachable from the internet. WireGuard is the only remote admin path, and it has its own keypair-based auth.
|
||||||
|
6. **Secrets aren't in git.** Configs reference secrets by name; values live in a password manager.
|
||||||
|
|
||||||
|
## Update cadence
|
||||||
|
|
||||||
|
- **Edge components** (firewall, reverse proxies, identity provider) — patched promptly when CVEs land. Highest blast radius if compromised.
|
||||||
|
- **Hypervisor and backup server** — quarterly review, with security patches applied out-of-cycle when needed.
|
||||||
|
- **Application LXCs** — rolling updates on a regular schedule, with the sensitive ones (password manager, photos, identity) bumped ahead of less-sensitive ones.
|
||||||
|
- **Container images** — re-pulled on the same rolling schedule.
|
||||||
|
|
||||||
|
A version tracker in the private repo records what's running where, so drift is visible at a glance.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
- Hypervisor-level backups to a dedicated backup server on a separate VLAN.
|
||||||
|
- Conservative retention, with the most recent backups always preserved no matter what gets pruned.
|
||||||
|
- Backups verified periodically; restores exercised on a non-production host so I find out if something is broken *before* I need it.
|
||||||
|
- A documented rebuild order means the lab can come back from cold in a few hours, assuming I have physical access to the firewall.
|
||||||
|
|
||||||
|
## Credentials & rotation
|
||||||
|
|
||||||
|
- Identity provider passwords: in a password manager, used through it.
|
||||||
|
- Service-to-service secrets (API tokens, DB passwords): rotated when there's a reason to (component change, suspected exposure), not on a fixed calendar.
|
||||||
|
- VPN credentials: per-device. Each remote client has its own keypair.
|
||||||
|
|
||||||
|
## Honest limitations
|
||||||
|
|
||||||
|
This is a learning environment, not a hardened production estate. A couple of things I'd rather be upfront about:
|
||||||
|
|
||||||
|
- **No HA.** One hypervisor, one firewall. The mitigation isn't redundancy, it's a tested rebuild path and conservative backups.
|
||||||
|
- **One-person ops.** Everything runs as it would in a prod environment with on-call staff, except there's only me. The choice of tooling reflects that — anything that needs constant attention has been swapped out for something simpler.
|
||||||
|
|
||||||
|
Neither of those is unmanaged risk. They're scoped accepted risks, reviewed quarterly in the private repo.
|
||||||
|
|
||||||
|
## Security contact
|
||||||
|
|
||||||
|
Found something concerning about a public-facing endpoint? `admin@lerkolabs.com`. I'll respond.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Services
|
||||||
|
|
||||||
|
Everything I'm running, grouped by what it does. URLs, ports, and which host runs what are operational details — those live in the private repo.
|
||||||
|
|
||||||
|
## Identity & access
|
||||||
|
|
||||||
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Authentik | SSO for everything internal. OIDC where the app supports it, Caddy forward auth where it doesn't. |
|
||||||
|
| Pi-hole | DNS for the LAN, ad blocking, and the source of truth for internal hostnames. |
|
||||||
|
| WireGuard | The only way in from outside. All admin work happens through the tunnel. |
|
||||||
|
|
||||||
|
## Reverse proxy & TLS
|
||||||
|
|
||||||
|
Two Caddy instances, by design:
|
||||||
|
|
||||||
|
- **Internal Caddy** — fronts everything internal. Reachable from inside the LAN or via VPN. Does most of the routing.
|
||||||
|
- **DMZ Caddy** — fronts the small set of things I want public. Lives on its own VLAN with no inbound access to internal services beyond a tight, firewall-enforced allowlist.
|
||||||
|
|
||||||
|
Both use Cloudflare DNS-01 for ACME, which is how internal-only services get valid public certs without ever being exposed to the internet for issuance.
|
||||||
|
|
||||||
|
## Productivity & knowledge
|
||||||
|
|
||||||
|
| Service | What it replaces |
|
||||||
|
|---|---|
|
||||||
|
| Outline | Notion / Confluence |
|
||||||
|
| Vikunja | Todoist / Asana |
|
||||||
|
| Hoarder | Pocket / Raindrop |
|
||||||
|
| Memos | Apple Notes (the quick-capture kind) |
|
||||||
|
| FreshRSS | Feedly |
|
||||||
|
| Bytestash | gist / pastebin |
|
||||||
|
| Filebrowser | Dropbox-style file access |
|
||||||
|
| Baikal | iCloud calendar/contacts (CalDAV / CardDAV) |
|
||||||
|
|
||||||
|
## Money
|
||||||
|
|
||||||
|
| Service | What it replaces |
|
||||||
|
|---|---|
|
||||||
|
| Actual Budget | YNAB / Mint |
|
||||||
|
| Ghostfolio | Personal Capital |
|
||||||
|
|
||||||
|
## Operations & day-to-day
|
||||||
|
|
||||||
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Grist | Lightweight relational tracking — anything that wants to be in a spreadsheet but shouldn't be |
|
||||||
|
| Glance | Personal landing page / dashboard |
|
||||||
|
| Traggo | Time tracking |
|
||||||
|
|
||||||
|
## Media
|
||||||
|
|
||||||
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Plex | Media library (legacy clients) |
|
||||||
|
| Jellyfin | Media library (primary, open source) |
|
||||||
|
| *arr stack | Library automation |
|
||||||
|
| qBittorrent | Downloads |
|
||||||
|
| Immich | Self-hosted Google Photos replacement |
|
||||||
|
|
||||||
|
## Home / IoT
|
||||||
|
|
||||||
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Home Assistant OS | Home automation hub |
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Vaultwarden | Bitwarden-compatible password manager. **Planned, not deployed yet.** |
|
||||||
|
|
||||||
|
## Bots & automation
|
||||||
|
|
||||||
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Vocard | Discord music bot |
|
||||||
|
| MonitorRSS | RSS-to-Discord notifications |
|
||||||
|
| ntfy | Push notifications for ops alerts |
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Victoria Metrics | Time-series store |
|
||||||
|
| Grafana | Dashboards |
|
||||||
|
| Beszel | Lightweight host metrics |
|
||||||
|
| Uptime Kuma | Synthetic uptime checks |
|
||||||
|
|
||||||
|
## Public services
|
||||||
|
|
||||||
|
A small, intentional set of things that are reachable from the open internet. They all sit behind the DMZ reverse proxy on a VLAN with no inbound access to internal subnets.
|
||||||
|
|
||||||
|
| Service | Why it's public |
|
||||||
|
|---|---|
|
||||||
|
| Portfolio | It's a portfolio. |
|
||||||
|
| Self-hosted Git | Where you're reading this. |
|
||||||
|
| SSO endpoint | Has to be reachable for an OIDC flow on one specific public-facing service (the Discord bot dashboard). It's the only internal-VLAN backend the public proxy is allowed to talk to, and the firewall enforces that — not just the proxy config. |
|
||||||
|
| One Authentik-gated app | The Discord bot dashboard. Public so I can hit it from outside the LAN; gated by Authentik forward auth before anything responds. |
|
||||||
|
|
||||||
|
## Who can access what
|
||||||
|
|
||||||
|
Three audiences, three levels:
|
||||||
|
|
||||||
|
- **Internet, anonymous** — sees only the small public set above.
|
||||||
|
- **Internet, signed into Authentik** — same as above, plus access to the Authentik-gated public services.
|
||||||
|
- **Connected via WireGuard** — gets everything: internal apps and admin surfaces (hypervisor, firewall, backup server, network controller, monitoring). This is the only way to reach any admin surface.
|
||||||
|
|
||||||
|
The WFH and IoT VLANs are deliberately *outside* this access model. Those are for me-as-a-user (work laptop, smart devices), not me-as-an-operator. They never see the internal service plane.
|
||||||
Reference in New Issue
Block a user