docs: publish 2026-04-26
This commit is contained in:
@@ -1,17 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this homelab, in reverse chronological order.
|
|
||||||
|
|
||||||
## [2026.04.3] — 2026-04-20
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Hardened public tree: generalized hardware SKUs, removed pinned software versions, timezone, admin URLs, DMZ service mapping, and technical-debt enumeration.
|
|
||||||
- Public repository history reset — this is the genesis commit of the hardened tree.
|
|
||||||
- Retired `git subtree` publishing in favor of a direct publish workflow.
|
|
||||||
|
|
||||||
## [2026.04.1] — 2026-04-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Initial repo structure with public/private split
|
|
||||||
- Public documentation: services, network, decisions, security, inventory, runbooks
|
|
||||||
- Rebuild sequence (8-phase ordered recovery)
|
|
||||||
@@ -1,55 +1,63 @@
|
|||||||
# homelab
|
# homelab
|
||||||
|
|
||||||
Personal homelab running 24/7 on production-grade hardware. Domain: `lerkolabs.com`. Single Proxmox host running 9 LXC containers + 2 VMs across 8 isolated VLANs with 20+ self-hosted services.
|
My homelab. Self-hosted, segmented, runs 24/7 on a single Proxmox host. Domain: `lerkolabs.com`.
|
||||||
|
|
||||||
## At a Glance
|
## Why I built this
|
||||||
|
|
||||||
| Component | Technology |
|
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 |
|
| Hypervisor | Proxmox VE |
|
||||||
| Firewall | pfSense (Intel N100) |
|
| Firewall | pfSense (low-power x86) |
|
||||||
| Switching | TP-Link Omada (managed VLANs) |
|
| Switching | TP-Link Omada (managed VLANs) |
|
||||||
| Reverse Proxy | Caddy + Cloudflare DNS-01 |
|
| Reverse proxy | Caddy (Cloudflare DNS-01) |
|
||||||
| Auth | Authentik SSO (OIDC + forward auth) |
|
| Identity | Authentik (OIDC + forward auth) |
|
||||||
| DNS | Pi-hole → pfSense Unbound → Cloudflare |
|
| DNS | Pi-hole → Unbound → Cloudflare |
|
||||||
| VPN | WireGuard, UDP 51820 |
|
| Remote access | WireGuard |
|
||||||
| Monitoring | Victoria Metrics + Grafana + Beszel |
|
| Monitoring | Victoria Metrics + Grafana + Beszel |
|
||||||
| Backups | Proxmox Backup Server (PBS) |
|
| Backups | Proxmox Backup Server |
|
||||||
|
|
||||||
## Compute Layout
|
## Scope
|
||||||
|
|
||||||
| Container | IP | Cores | RAM | What Runs |
|
One Proxmox host. One firewall. One person operating it. About ten LXCs and a couple of VMs running roughly twenty services across seven VLANs.
|
||||||
|-----------|-----|-------|-----|-----------|
|
|
||||||
| `pihole` | 10.2.0.11 | 1 | 512MB | Pi-hole DNS + ad blocking |
|
|
||||||
| `auth` | 10.2.0.25 | 1 | 512MB | Authentik SSO |
|
|
||||||
| `infra` | 10.2.0.20 | 2 | 1GB | Caddy reverse proxy, ntfy |
|
|
||||||
| `monitor` | 10.2.0.51 | 4 | 4GB | Victoria Metrics, Grafana, Beszel |
|
|
||||||
| `apps` | 10.2.0.60 | 4 | 6GB | 15+ productivity apps (Docker Compose) |
|
|
||||||
| `vault` | 10.2.0.X | 1 | 256MB | Vaultwarden (isolated) |
|
|
||||||
| `servarr` (VM) | — | 4 | 8GB | Plex, Jellyfin, *arr stack, qBittorrent |
|
|
||||||
| `haos` (VM) | — | 2 | 4GB | Home Assistant OS |
|
|
||||||
|
|
||||||
## DMZ (Public-Facing)
|
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.
|
||||||
|
|
||||||
| Container | IP | Service |
|
## How I think about it
|
||||||
|-----------|-----|---------|
|
|
||||||
| `caddy-dmz` | 10.99.0.20 | Public reverse proxy |
|
|
||||||
| `gitea` | 10.99.0.22 | gitea.lerkolabs.com |
|
|
||||||
| `portfolio` | 10.99.0.23 | lerkolabs.com |
|
|
||||||
|
|
||||||
## Key Principles
|
- **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.
|
||||||
|
|
||||||
- All services require Authentik authentication — no anonymous access
|
## Documented here
|
||||||
- No management ports exposed to internet — all admin access via WireGuard first
|
|
||||||
- Caddy handles TLS termination; internal services run plain HTTP
|
|
||||||
- Secrets never committed — all referenced by Vaultwarden entry name
|
|
||||||
|
|
||||||
## Navigation
|
| 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 |
|
||||||
|
|
||||||
- [Services](docs/SERVICES.md) — full service registry with URLs and access matrix
|
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.
|
||||||
- [Network](docs/NETWORK.md) — VLANs, firewall policy, DNS architecture, physical topology
|
|
||||||
- [Decisions](docs/DECISIONS.md) — architecture decision records (D001–D010)
|
## What I learned along the way
|
||||||
- [Security](docs/SECURITY.md) — security posture, auth layers, update cadence, known debt
|
|
||||||
- [Inventory](docs/INVENTORY.md) — hardware inventory
|
- 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.
|
||||||
- [Rebuild](REBUILD.md) — disaster recovery sequence (8 phases)
|
- 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.
|
||||||
- [Setup guides](setup/) — per-service installation and configuration
|
- 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.*
|
||||||
|
|||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
# Rebuild
|
|
||||||
|
|
||||||
Ordered recovery sequence from scratch or after catastrophic failure. **Nothing works until the thing before it works.** For step-by-step setup, see individual service [setup guides](setup/).
|
|
||||||
|
|
||||||
## Phase 1 — Network Foundation
|
|
||||||
|
|
||||||
1. **pfSense** — restore `config.xml`; verify WAN gets public IP (IP Passthrough active on BGW320); verify all VLAN interfaces up + DHCP serving; verify firewall rules loaded
|
|
||||||
2. **Omada Switch** — restore controller backup; verify port VLANs match [Network](docs/NETWORK.md) topology; verify trunk port carrying all VLANs tagged
|
|
||||||
3. **Access points** — auto-adopt into Omada Controller; verify SSIDs on correct VLANs
|
|
||||||
|
|
||||||
**Gate:** LAN device gets IP and reaches internet.
|
|
||||||
|
|
||||||
## Phase 2 — DNS
|
|
||||||
|
|
||||||
4. **Pi-hole LXC** — restore from PBS snapshot (or fresh deploy); restore Teleporter backup; verify all local DNS records → 10.2.0.20 (Caddy); verify ad blocking active
|
|
||||||
5. **pfSense DNS Resolver** — auto-configured from `config.xml`; verify Pi-hole is upstream for all VLANs
|
|
||||||
|
|
||||||
**Gate:** `nslookup outline.lerkolabs.com` returns 10.2.0.20 from LAN.
|
|
||||||
|
|
||||||
## Phase 3 — Reverse Proxy + TLS
|
|
||||||
|
|
||||||
6. **Infra LXC (Caddy)** — restore from PBS (or fresh deploy); verify Cloudflare API token valid; start Caddy — certs auto-issue (allow 2–3 min); add Pi-hole DNS record: `*.lerkolabs.com → 10.2.0.20`
|
|
||||||
|
|
||||||
**Gate:** `curl -I https://pihole.lerkolabs.com` returns HTTP/2 200.
|
|
||||||
|
|
||||||
## Phase 4 — Auth
|
|
||||||
|
|
||||||
7. **Auth LXC (Authentik)** — restore from PBS; verify admin accessible at `https://auth.lerkolabs.com`; verify OIDC apps configured (Outline, Gitea, Vikunja); verify forward auth flows
|
|
||||||
|
|
||||||
## Phase 5 — Secrets
|
|
||||||
|
|
||||||
8. **Vault LXC (Vaultwarden)** — restore from PBS; verify accessible at `https://vault.lerkolabs.com`; confirm all credentials accessible before proceeding
|
|
||||||
|
|
||||||
## Phase 6 — Core Services
|
|
||||||
|
|
||||||
9. **Apps LXC** — restore from PBS (or fresh deploy); start shared Postgres + Redis first; bring up services one by one: Outline → Gitea → Vikunja → Ghostfolio → Hoarder → Grist → Glance → Actual → FreshRSS → Memos → Traggo → Baikal → Filebrowser → Bytestash
|
|
||||||
10. **Monitor LXC** — restore from PBS; verify Grafana dashboards loading; verify Beszel agents reporting from all LXCs; verify Victoria Metrics receiving metrics
|
|
||||||
|
|
||||||
## Phase 7 — VMs
|
|
||||||
|
|
||||||
11. **Servarr VM** — restore from PBS; verify Plex/Jellyfin accessible; verify arr stack healthy; verify Gluetun VPN tunnel active for qBittorrent
|
|
||||||
12. **Home Assistant OS VM** — restore from PBS (or HAOS backup); verify integrations reconnect
|
|
||||||
|
|
||||||
## Phase 8 — VPN
|
|
||||||
|
|
||||||
13. **WireGuard** — restored with `config.xml`; verify peer configs valid; test from cellular; if keys rotated, distribute new configs
|
|
||||||
|
|
||||||
## Post-Rebuild Checklist
|
|
||||||
|
|
||||||
- [ ] Internet works from LAN devices
|
|
||||||
- [ ] DNS resolves internal and external names
|
|
||||||
- [ ] All `*.lerkolabs.com` reachable via HTTPS
|
|
||||||
- [ ] Authentik SSO working (log into Outline via Authentik)
|
|
||||||
- [ ] WireGuard connects from external network
|
|
||||||
- [ ] Vaultwarden accessible and credentials intact
|
|
||||||
- [ ] All Docker containers healthy in Beszel
|
|
||||||
- [ ] PBS scheduled backups running
|
|
||||||
- [ ] Pi-hole blocking ads
|
|
||||||
- [ ] Home Assistant automations running
|
|
||||||
- [ ] Media stack healthy (Plex/Jellyfin playback works)
|
|
||||||
+34
-10
@@ -1,15 +1,39 @@
|
|||||||
# Authentication Flow
|
# 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
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
User->>Caddy: HTTPS request
|
participant U as User
|
||||||
Caddy->>Authentik: Forward auth check
|
participant C as Caddy<br/>(reverse proxy)
|
||||||
Authentik-->>Caddy: 401 if unauthenticated
|
participant A as Authentik<br/>(IdP)
|
||||||
Caddy-->>User: Redirect to auth.lerkolabs.com
|
participant S as Internal service
|
||||||
User->>Authentik: Login (OIDC or forward auth)
|
|
||||||
Authentik-->>User: Session cookie
|
U->>C: HTTPS request
|
||||||
User->>Caddy: HTTPS request + cookie
|
C->>A: Forward auth check
|
||||||
Caddy->>Authentik: Forward auth check
|
A-->>C: 401 (no session)
|
||||||
Authentik-->>Caddy: 200 OK
|
C-->>U: 302 → auth.lerkolabs.com
|
||||||
Caddy->>Service: Proxy request
|
|
||||||
|
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.
|
||||||
|
|||||||
+54
-5
@@ -1,9 +1,58 @@
|
|||||||
# DNS Resolution Chain
|
# 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
|
```mermaid
|
||||||
graph LR
|
graph LR
|
||||||
D[Device] --> PH[Pi-hole\n10.2.0.11]
|
CLIENT[Client<br/>most VLANs] --> PIHOLE[Pi-hole<br/>filtering + cache]
|
||||||
PH --> UB[pfSense Unbound\n10.x.0.1]
|
PIHOLE -->|miss| UNBOUND[Unbound on firewall<br/>recursive + DNSSEC]
|
||||||
UB --> CF[Cloudflare\n1.1.1.1]
|
UNBOUND --> UPSTREAM[Cloudflare<br/>fallback only]
|
||||||
PH -- "*.lerkolabs.com" --> CADDY[Caddy\n10.2.0.20]
|
|
||||||
|
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.
|
||||||
|
|||||||
+89
-11
@@ -1,15 +1,93 @@
|
|||||||
# Network Topology
|
# 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
|
```mermaid
|
||||||
graph TD
|
graph TB
|
||||||
ONT[AT&T Fiber ONT] --> BGW[BGW320 IP Passthrough]
|
subgraph UNTRUSTED["Untrusted — internet only, no internal access"]
|
||||||
BGW --> PF[pfSense N100]
|
GUEST[Guest WiFi]
|
||||||
PF --> SW[Omada Switch]
|
IOT[IoT]
|
||||||
SW --> MGMT[VLAN 1000 MGMT\n10.0.0.0/24]
|
WFH[Work-from-home]
|
||||||
SW --> LAN[VLAN 1010 LAN\n10.1.0.0/24]
|
end
|
||||||
SW --> HL[VLAN 1020 Homelab\n10.2.0.0/24]
|
|
||||||
SW --> GUEST[VLAN 1030 Guests\n10.3.0.0/24]
|
subgraph PUBLIC["Public-facing"]
|
||||||
SW --> IOT[VLAN 1040 IoT\n10.4.0.0/24]
|
DMZ[DMZ<br/>reverse proxy + public services]
|
||||||
SW --> WFH[VLAN 1050 WFH\n10.5.0.0/24]
|
end
|
||||||
SW --> DMZ[VLAN 1 DMZ\n10.99.0.0/24]
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
# Decisions
|
|
||||||
|
|
||||||
Architecture Decision Records (ADR-lite). Key choices and rationale.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D001 — Public/private split: git subtree + push script + pre-push hook
|
|
||||||
|
|
||||||
**Decision:** Public content lives under `public/`. Push script uses `git subtree push --prefix=public` to publish it as root to the public remote. Pre-push hook blocks direct pushes that bypass the script.
|
|
||||||
**Why:** git-filter-repo is designed for one-time rewrites, not recurring pushes. Separate branches require manual discipline. git subtree is pure git, produces clean history on the public remote, and the script stays two lines.
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D002 — Public remote: self-hosted Gitea → GitHub mirror
|
|
||||||
|
|
||||||
**Decision:** Public remote is a self-hosted Gitea instance. Gitea mirrors to GitHub automatically.
|
|
||||||
**Why:** Matches existing portfolio site setup. Local workflow only pushes to Gitea; GitHub propagation is transparent. No extra tooling needed.
|
|
||||||
**Constraint:** Filtering must be airtight before push — whatever reaches Gitea lands on GitHub within seconds.
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D003 — Private remote: private repo on same Gitea instance
|
|
||||||
|
|
||||||
**Decision:** Private remote is a separate private repository on the same self-hosted Gitea instance.
|
|
||||||
**Why:** Easiest path — infrastructure already exists, one tool to manage.
|
|
||||||
**Risk:** Single point of failure. If Gitea host goes down, both remotes are inaccessible. Accepted for now.
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D004 — Shared Postgres + Redis in apps LXC
|
|
||||||
|
|
||||||
**Decision:** Single Postgres instance with multiple databases + single Redis instance, both in the `apps` LXC. All productivity apps share this infrastructure.
|
|
||||||
**Why:** Avoids 15 separate DB containers. A single init script provisions all schemas on first run.
|
|
||||||
**Risk:** If Postgres goes down, all productivity apps go down simultaneously.
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D005 — AT&T gateway kept in-line (IP Passthrough, not EAP bypass)
|
|
||||||
|
|
||||||
**Decision:** BGW320 stays in-line with IP Passthrough mode (DHCPS-fixed to pfSense WAN MAC). pfSense gets the public IP directly. Gateway WiFi disabled.
|
|
||||||
**Why:** AT&T locks 802.1X certificate auth to their gateway hardware. EAP proxy bypass breaks on AT&T firmware updates and only saves 1–2ms latency. True bridge mode not supported.
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D006 — Caddy over NGINX Proxy Manager, with Cloudflare DNS-01
|
|
||||||
|
|
||||||
**Decision:** Caddy with `caddy-dns/cloudflare` plugin. DNS-01 challenge via Cloudflare API. All `*.lerkolabs.com` subdomains → 10.2.0.20 in Pi-hole. Caddy terminates SSL, proxies to backends.
|
|
||||||
**Why:** Single Caddyfile, auto-cert, no UI overhead. No port 80/443 needed on WAN.
|
|
||||||
**Alternatives:** NGINX Proxy Manager (more UI overhead), Traefik (more complex config, same result), self-signed certs (browser warnings).
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D007 — WireGuard over OpenVPN
|
|
||||||
|
|
||||||
**Decision:** WireGuard on pfSense, UDP 51820, VPN subnet 10.200.0.0/24. VPN clients get same access as LAN.
|
|
||||||
**Why:** Lower latency, better mobile battery life, ~600Mbps on the N100. OpenVPN adds complexity with no advantage here.
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D008 — Authentik over Authelia
|
|
||||||
|
|
||||||
**Decision:** Authentik as SSO provider for all services.
|
|
||||||
**Why:** Full OIDC provider + forward auth in one. Lets services like Outline, Gitea, and Vikunja use real SSO rather than just a login gate. Authelia only does forward auth.
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D009 — Pi-hole in Homelab VLAN (1020), not MGMT
|
|
||||||
|
|
||||||
**Decision:** Pi-hole at 10.2.0.11 in VLAN 1020. Firewall allows port 53 inbound from all VLANs. MGMT VLAN uses pfSense as primary DNS.
|
|
||||||
**Why:** Placing Pi-hole in MGMT would require allowing all VLANs to reach MGMT — larger attack surface than filtering DNS traffic from Homelab VLAN.
|
|
||||||
**Status:** decided
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D010 — Intel N100 for pfSense
|
|
||||||
|
|
||||||
**Decision:** Intel N100 mini PC. 4-core 3.4GHz, ~6W idle. Handles 2–3Gbps routing, 600–900Mbps WireGuard.
|
|
||||||
**Why:** Right-sized for 1Gbps fiber with headroom. Raspberry Pi insufficient for 1Gbps + VPN. Full rack server overkill power draw.
|
|
||||||
**Status:** decided
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Inventory
|
|
||||||
|
|
||||||
Hardware inventory — make/model, role, specs. See [README](../README.md) for how everything fits together.
|
|
||||||
|
|
||||||
## Active Hardware
|
|
||||||
|
|
||||||
| Device | Role | Model | Notes |
|
|
||||||
|--------|------|-------|-------|
|
|
||||||
| Proxmox host | Hypervisor | Dual-socket Xeon rackmount | 32c / 128GB RAM |
|
|
||||||
| Proxmox backup server | Backup server | HP desktop | Backup all LXCs + VMs |
|
|
||||||
| pfSense router | Firewall / VPN / DHCP / routing | Netgate 1100 | ~6W idle, handles 2–3Gbps routing + 600Mbps WireGuard |
|
|
||||||
| Managed switch | VLAN switching | TP-Link Omada L2+ managed | All port VLANs managed via Omada Controller |
|
|
||||||
| Access points | WiFi | TP-Omada WiFi 6 | Auto-adopted by Omada Controller |
|
|
||||||
| AT&T Gateway | ISP ONT | AT&T-issued | IP Passthrough |
|
|
||||||
|
|
||||||
## pfSense Box Detail
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| CPU | Intel N100 (4-core, 3.4GHz) |
|
|
||||||
| Idle power | ~6W |
|
|
||||||
| Routing throughput | 2–3Gbps |
|
|
||||||
| WireGuard throughput | ~600Mbps |
|
|
||||||
| pfSense version | pinned |
|
|
||||||
|
|
||||||
## Proxmox Host Detail
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| CPU | Intel Xeon E5-2683 v4 (32-core, 2.10GHz) |
|
|
||||||
| RAM | 128 GB |
|
|
||||||
| Boot drive | 128 GB |
|
|
||||||
| Storage | 1500 GB |
|
|
||||||
| Proxmox version | pinned |
|
|
||||||
|
|
||||||
## Proxmox Backup Detail |
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| CPU | Intel i5-4590T (4-core, 2.00GHz) |
|
|
||||||
| RAM | 16 GB |
|
|
||||||
| Boot drive | 256 GB |
|
|
||||||
| Storage | 2 TB |
|
|
||||||
| PBS version | pinned |
|
|
||||||
|
|
||||||
## Licensing / Subscriptions
|
|
||||||
|
|
||||||
| Service | Type | Notes |
|
|
||||||
|---------|------|-------|
|
|
||||||
| Cloudflare | Free | lerkolabs.com DNS + DNS-01 challenge |
|
|
||||||
| Let's Encrypt | Free | Via Caddy — auto-renewal |
|
|
||||||
| AT&T Fiber | Monthly | 1Gbps symmetric |
|
|
||||||
| Domain Name | Annually | `lerkolabs.com` |
|
|
||||||
|
|
||||||
## Backup (PBS)
|
|
||||||
|
|
||||||
Nightly backups + weekly GC for all LXCs + VMs, managed by Proxmox Backup Server.
|
|
||||||
|
|
||||||
**Covered:** pihole, auth, infra, monitor, apps, vault, servarr VM, haos VM
|
|
||||||
|
|
||||||
### Retention
|
|
||||||
|
|
||||||
| Keep | Amount |
|
|
||||||
|------|--------|
|
|
||||||
| Last | 3 |
|
|
||||||
| Daily | 13 |
|
|
||||||
| Weekly | 8 |
|
|
||||||
| Monthly | 11 |
|
|
||||||
| Yearly | 2 |
|
|
||||||
+40
-106
@@ -1,130 +1,64 @@
|
|||||||
# Network
|
# Network
|
||||||
|
|
||||||
VLAN map, firewall policy, DNS architecture, and physical topology. See [README](../README.md) for the big picture and [Services](SERVICES.md) for what lives where.
|
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.
|
||||||
|
|
||||||
## VLAN Map
|
## Why segmentation matters here
|
||||||
|
|
||||||
| VLAN ID | Name | Subnet | Gateway | DHCP Range | DNS |
|
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.
|
||||||
|---------|------|--------|---------|------------|-----|
|
|
||||||
| 1000 | MGMT | 10.0.0.0/24 | 10.0.0.1 | 10.0.0.100–150 | pfSense only |
|
|
||||||
| 1010 | LAN | 10.1.0.0/24 | 10.1.0.1 | 10.1.0.100–200 | Pi-hole → pfSense |
|
|
||||||
| 1020 | Homelab | 10.2.0.0/24 | 10.2.0.1 | 10.2.0.100–200 | Pi-hole → pfSense |
|
|
||||||
| 1030 | Guests | 10.3.0.0/24 | 10.3.0.1 | 10.3.0.100–250 | Pi-hole → pfSense |
|
|
||||||
| 1040 | IoT | 10.4.0.0/24 | 10.4.0.1 | 10.4.0.100–250 | Pi-hole → pfSense |
|
|
||||||
| 1050 | WFH | 10.5.0.0/24 | 10.5.0.1 | 10.5.0.100–200 | pfSense only |
|
|
||||||
| 1099 | DMZ | 10.99.0.0/24 | 10.99.0.1 | static only | pfSense only |
|
|
||||||
| — | VPN | 10.200.0.0/24 | pfSense | assigned by WG | Pi-hole → pfSense |
|
|
||||||
|
|
||||||
## Firewall Policy
|
The model here is **trust-tier VLANs** with explicit policy between them. Every tier has a documented purpose and a defined inbound/outbound posture.
|
||||||
|
|
||||||
Default: **deny all inter-VLAN unless explicitly allowed.**
|
## Trust tiers
|
||||||
|
|
||||||
| VLAN | Policy Summary |
|
Seven VLANs, organized roughly by how much I trust what's on them:
|
||||||
|------|---------------|
|
|
||||||
| LAN (1010) | Full internet; can reach Homelab + MGMT; blocked from Guest/IoT/WFH |
|
|
||||||
| Homelab (1020) | Internet for updates (HTTP/S, SSH, NTP); cannot initiate to other VLANs |
|
|
||||||
| Guests (1030) | Internet only — hard block on all RFC1918 |
|
|
||||||
| IoT (1040) | Internet + Home Assistant (explicit rule); blocked from LAN |
|
|
||||||
| WFH (1050) | Internet only; pfSense DNS only; no personal network access |
|
|
||||||
| MGMT (1000) | Updates + NTP outbound; inbound from LAN + VPN only |
|
|
||||||
| DMZ (1099) | HTTP/S + NTP outbound; hard-blocked from all internal VLANs |
|
|
||||||
| VPN (10.200.0.0/24) | Same as LAN: Homelab + MGMT web GUI + Pi-hole DNS |
|
|
||||||
|
|
||||||
## Static IP Reservations
|
| 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. |
|
||||||
|
|
||||||
### VLAN 1000 — MGMT
|
## Policy posture
|
||||||
|
|
||||||
| IP | Device |
|
- **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.
|
||||||
| 10.0.0.1 | pfSense MGMT |
|
- **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.
|
||||||
| 10.0.0.2 | Omada Switch |
|
- **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.
|
||||||
| 10.0.0.3 | Guest AP |
|
- **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.
|
||||||
| 10.0.0.4 | IoT AP |
|
|
||||||
|
|
||||||
### VLAN 1010 — LAN
|
## DNS
|
||||||
|
|
||||||
| IP | Device |
|
Three layers, each doing one job:
|
||||||
|----|--------|
|
|
||||||
| 10.1.0.1 | pfSense LAN gateway |
|
|
||||||
|
|
||||||
### VLAN 1020 — Homelab
|
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.
|
||||||
|
|
||||||
| IP | Device |
|
**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.
|
||||||
|----|--------|
|
|
||||||
| 10.2.0.1 | pfSense Homelab gateway |
|
|
||||||
| 10.2.0.10 | Proxmox |
|
|
||||||
| 10.2.0.11 | Pi-hole |
|
|
||||||
| 10.2.0.20 | Caddy (infra LXC) |
|
|
||||||
| 10.2.0.21 | Vaultwarden (vault LXC) |
|
|
||||||
| 10.2.0.25 | Authentik (auth LXC) |
|
|
||||||
| 10.2.0.51 | Monitor LXC |
|
|
||||||
| 10.2.0.60 | Apps LXC |
|
|
||||||
|
|
||||||
### VLAN 1099 — DMZ
|
**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.
|
||||||
|
|
||||||
| IP | Device |
|
## Internet exposure
|
||||||
|----|--------|
|
|
||||||
| 10.99.0.1 | pfSense DMZ gateway |
|
|
||||||
| 10.99.0.20 | Public Service A |
|
|
||||||
| 10.99.0.22 | Public Service B |
|
|
||||||
| 10.99.0.23 | Public Service C |
|
|
||||||
|
|
||||||
## IP Block Allocation (VLAN 1020)
|
Three ports forwarded from WAN to internal:
|
||||||
|
|
||||||
| Block | Purpose |
|
- **HTTP / HTTPS** — to the DMZ reverse proxy. Serves the small public service set.
|
||||||
|-------|---------|
|
- **WireGuard** — to the firewall. The only remote admin path.
|
||||||
| 10.2.0.1–9 | Infrastructure (gateway, pfSense interfaces) |
|
|
||||||
| 10.2.0.10–19 | Network critical (Proxmox, Pi-hole) |
|
|
||||||
| 10.2.0.20–29 | Auth / Proxy (Caddy, Authentik, Vaultwarden) |
|
|
||||||
| 10.2.0.30–39 | Observability |
|
|
||||||
| 10.2.0.40–49 | Dev tools |
|
|
||||||
| 10.2.0.50–59 | Data |
|
|
||||||
| 10.2.0.60–69 | Apps |
|
|
||||||
| 10.2.0.70–79 | Files |
|
|
||||||
| 10.2.0.80–99 | Media |
|
|
||||||
| 10.2.0.100+ | DHCP pool (dynamic) |
|
|
||||||
|
|
||||||
## DNS Architecture
|
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
|
||||||
Device → Pi-hole (10.2.0.11)
|
|
||||||
↓
|
|
||||||
pfSense Unbound (10.x.0.1) — local records + DHCP hostnames
|
|
||||||
↓
|
|
||||||
Cloudflare 1.1.1.1 (upstream)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Pi-hole: ad/tracker blocking, local DNS records (all `*.lerkolabs.com` → 10.2.0.20 Caddy), query logging
|
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.
|
||||||
- pfSense Unbound: DHCP hostname registration, backup resolver if Pi-hole is down
|
|
||||||
- WFH VLAN: pfSense DNS only — Pi-hole unreachable by design
|
|
||||||
|
|
||||||
## Physical Topology
|
## Things that are easy to overlook
|
||||||
|
|
||||||
```
|
A couple of things worth being explicit about, because they bit me at some point:
|
||||||
AT&T Fiber ONT
|
|
||||||
|
|
|
||||||
AT&T BGW320 (IP Passthrough)
|
|
||||||
|
|
|
||||||
pfSense N100 (WAN/LAN)
|
|
||||||
|
|
|
||||||
Omada Managed Switch
|
|
||||||
├── Trunk port → pfSense (all VLANs tagged)
|
|
||||||
├── VLAN 1000 — MGMT devices
|
|
||||||
├── VLAN 1010 — Desktop / LAN
|
|
||||||
├── VLAN 1020 — Proxmox / Homelab servers
|
|
||||||
├── VLAN 1030 — Guest WiFi AP
|
|
||||||
├── VLAN 1040 — IoT WiFi AP
|
|
||||||
├── VLAN 1050 — Work laptop
|
|
||||||
└── VLAN 1099 — DMZ
|
|
||||||
```
|
|
||||||
|
|
||||||
## WireGuard VPN
|
- **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.
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Listen Port | 51820 UDP |
|
|
||||||
| VPN Subnet | 10.200.0.0/24 |
|
|
||||||
| Access granted | Homelab + MGMT web GUI + Pi-hole DNS |
|
|
||||||
| Access blocked | Guest, IoT, WFH |
|
|
||||||
|
|
||||||
No management ports (22, 8006, 443) exposed to the internet. WireGuard is the only inbound port on the WAN interface (aside from Cloudflare DNS-01 challenge traffic, which uses no inbound ports).
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# RUNBOOKS
|
|
||||||
|
|
||||||
_stub_
|
|
||||||
+38
-37
@@ -1,54 +1,55 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
Security posture — what's exposed, how auth works, update cadence, known debt. See [Network](NETWORK.md) for VLAN isolation details.
|
Posture and practices. No enumerated weaknesses or specific control parameters here — those live in the private repo where they belong.
|
||||||
|
|
||||||
## Internet-Exposed Ports
|
## Threat model
|
||||||
|
|
||||||
| Port | Protocol | Destination | Purpose |
|
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.
|
||||||
|------|----------|-------------|---------|
|
|
||||||
| 51820 | UDP | pfSense WAN | WireGuard VPN |
|
|
||||||
|
|
||||||
No management ports (22, 8006, 443) exposed to the internet. All admin access requires an active WireGuard connection first. Cloudflare DNS-01 challenge handles TLS — no port 80/443 needed on WAN.
|
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.
|
||||||
|
|
||||||
## Authentication Layers
|
## Layered controls
|
||||||
|
|
||||||
| Layer | Mechanism | Coverage |
|
The defenses are intentionally redundant. Any single layer failing should leave the next one intact.
|
||||||
|-------|-----------|----------|
|
|
||||||
| All web services | Authentik SSO (OIDC or forward auth) | 100% of `*.lerkolabs.com` |
|
|
||||||
| VPN | WireGuard pre-shared keys | Required for all remote access |
|
|
||||||
| pfSense | Web GUI + SSH key | VPN-only access |
|
|
||||||
| Proxmox | Web GUI + SSH key | VPN-only access |
|
|
||||||
| Secrets | Vaultwarden (isolated LXC) | All credentials |
|
|
||||||
|
|
||||||
No service is accessible anonymously. Guests and IoT have zero access to any internal service.
|
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.
|
||||||
|
|
||||||
## Secrets Policy
|
## Update cadence
|
||||||
|
|
||||||
- No plaintext secrets in any config file committed to the repo
|
- **Edge components** (firewall, reverse proxies, identity provider) — patched promptly when CVEs land. Highest blast radius if compromised.
|
||||||
- All secrets referenced by Vaultwarden entry name (e.g., `homelab/pfsense`)
|
- **Hypervisor and backup server** — quarterly review, with security patches applied out-of-cycle when needed.
|
||||||
- `.env` files in `.gitignore`
|
- **Application LXCs** — rolling updates on a regular schedule, with the sensitive ones (password manager, photos, identity) bumped ahead of less-sensitive ones.
|
||||||
- Vaultwarden lives in its own isolated LXC — no shared container
|
- **Container images** — re-pulled on the same rolling schedule.
|
||||||
|
|
||||||
## Certificate Management
|
A version tracker in the private repo records what's running where, so drift is visible at a glance.
|
||||||
|
|
||||||
| Domain | Provider | Method | Renewal |
|
## Backups
|
||||||
|--------|----------|--------|---------|
|
|
||||||
| `*.lerkolabs.com` | Let's Encrypt via Cloudflare | DNS-01 challenge | Automatic (Caddy) |
|
|
||||||
|
|
||||||
Caddy handles all cert issuance and renewal automatically. No manual action unless Cloudflare API token expires.
|
- 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.
|
||||||
|
|
||||||
## Update Cadence
|
## Credentials & rotation
|
||||||
|
|
||||||
| System | Frequency | Method |
|
- 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.
|
||||||
| pfSense | Monthly | Manual — System → Update |
|
- VPN credentials: per-device. Each remote client has its own keypair.
|
||||||
| Proxmox | Monthly | `apt update && apt dist-upgrade` |
|
|
||||||
| Pi-hole | Monthly | `pihole -up` |
|
|
||||||
| Docker services | Weekly | `docker compose pull && docker compose up -d` |
|
|
||||||
| Omada firmware | Quarterly | Omada Controller → Devices |
|
|
||||||
| AT&T Gateway | Automatic | AT&T pushes updates |
|
|
||||||
| WireGuard keys | Annually (or on peer change) | Rotate in pfSense VPN config |
|
|
||||||
|
|
||||||
## Known Technical Debt
|
## Honest limitations
|
||||||
|
|
||||||
Known gaps tracked privately.
|
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.
|
||||||
|
|||||||
+85
-74
@@ -1,97 +1,108 @@
|
|||||||
# Services
|
# Services
|
||||||
|
|
||||||
Full registry of what's running, where it lives, and how to reach it. See [README](../README.md) for compute layout and [Network](NETWORK.md) for VLAN/IP context.
|
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.
|
||||||
|
|
||||||
## Status Key
|
## Identity & access
|
||||||
|
|
||||||
| Symbol | Meaning |
|
| Service | What it does |
|
||||||
|--------|---------|
|
|---|---|
|
||||||
| ✅ | Running, healthy |
|
| Authentik | SSO for everything internal. OIDC where the app supports it, Caddy forward auth where it doesn't. |
|
||||||
| ⚠️ | Running, needs attention |
|
| Pi-hole | DNS for the LAN, ad blocking, and the source of truth for internal hostnames. |
|
||||||
| 🔴 | Down / broken |
|
| WireGuard | The only way in from outside. All admin work happens through the tunnel. |
|
||||||
| 🚧 | In progress |
|
|
||||||
| ➖ | Decommissioned |
|
|
||||||
|
|
||||||
## Core Network (VLAN 1000/1010/1020)
|
## Reverse proxy & TLS
|
||||||
|
|
||||||
Admin consoles at <service>.lerkolabs.com, VPN-gated.
|
Two Caddy instances, by design:
|
||||||
|
|
||||||
| Service | IP | Port | VLAN | Status | Notes |
|
- **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.
|
||||||
| pfSense | 10.1.0.1 / 10.0.0.1 | 443 | LAN/MGMT | ✅ | Firewall, DHCP, WireGuard VPN |
|
|
||||||
| Omada Switch | 10.0.0.2 | 443 | MGMT | ✅ | Managed switch, VLAN config |
|
|
||||||
| AT&T Gateway | 192.168.1.254 | 80 | — | ✅ | IP Passthrough only, WiFi disabled |
|
|
||||||
| Pi-hole | 10.2.0.11 | 80/53 | 1020 | ✅ | Primary DNS, ad blocking |
|
|
||||||
| Caddy (infra) | 10.2.0.20 | 80/443 | 1020 | ✅ | Reverse proxy, wildcard SSL via Cloudflare DNS-01 |
|
|
||||||
| ntfy | 10.2.0.20 | — | 1020 | ✅ | Push notifications (infra LXC) |
|
|
||||||
| Authentik | 10.2.0.25 | 9000 | 1020 | ✅ | SSO — OIDC + forward auth |
|
|
||||||
| Proxmox | 10.2.0.10 | 8006 | 1020 | ✅ | Hypervisor |
|
|
||||||
|
|
||||||
## Observability (monitor LXC — 10.2.0.51)
|
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.
|
||||||
|
|
||||||
Observability at <service>.lerkolabs.com, VPN-gated.
|
## Productivity & knowledge
|
||||||
|
|
||||||
| Service | Notes |
|
| Service | What it replaces |
|
||||||
|---------|-------|
|
|---|---|
|
||||||
| Grafana | Dashboards, alerting |
|
| Outline | Notion / Confluence |
|
||||||
| Victoria Metrics | Metrics storage |
|
| Vikunja | Todoist / Asana |
|
||||||
| Beszel | Container + host monitoring |
|
| 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) |
|
||||||
|
|
||||||
## Productivity Apps (apps LXC — 10.2.0.60)
|
## Money
|
||||||
|
|
||||||
All apps served at <service>.lerkolabs.com behind Authentik.
|
| Service | What it replaces |
|
||||||
|
|---|---|
|
||||||
|
| Actual Budget | YNAB / Mint |
|
||||||
|
| Ghostfolio | Personal Capital |
|
||||||
|
|
||||||
| Service | Auth | Purpose |
|
## Operations & day-to-day
|
||||||
|---------|------|---------|
|
|
||||||
| Outline | OIDC | Team wiki |
|
|
||||||
| Vikunja | OIDC | Task management |
|
|
||||||
| Ghostfolio | Forward auth | Portfolio tracking |
|
|
||||||
| Hoarder | Forward auth | Bookmark manager |
|
|
||||||
| Grist | Forward auth | Spreadsheets / data |
|
|
||||||
| Actual Budget | Forward auth | Personal budgeting |
|
|
||||||
| FreshRSS | Forward auth | RSS reader |
|
|
||||||
| Memos | Forward auth | Quick notes |
|
|
||||||
| Traggo | Forward auth | Time tracking |
|
|
||||||
| Baikal | Forward auth | CalDAV / CardDAV |
|
|
||||||
| Glance | Forward auth | Homepage dashboard |
|
|
||||||
| Filebrowser | Forward auth | File management |
|
|
||||||
| Bytestash | Forward auth | Snippet storage |
|
|
||||||
|
|
||||||
Shared infrastructure in apps LXC: single Postgres instance (multi-DB) + Redis. See [D004](DECISIONS.md#d004--shared-postgres--redis-in-apps-lxc).
|
| 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 |
|
||||||
|
|
||||||
## Secrets (vault LXC — 10.2.0.21)
|
## Media
|
||||||
|
|
||||||
Served at <service>.lerkolabs.com, VPN-gated.
|
| 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 |
|
||||||
|
|
||||||
| Service | Notes |
|
## Home / IoT
|
||||||
|---------|-------|
|
|
||||||
| Vaultwarden | Isolated LXC — not shared with apps |
|
|
||||||
|
|
||||||
## Media (servarr VM)
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Home Assistant OS | Home automation hub |
|
||||||
|
|
||||||
| Service | Purpose |
|
## Secrets
|
||||||
|---------|---------|
|
|
||||||
| Plex + Jellyfin | Media streaming |
|
|
||||||
| Sonarr / Radarr / Lidarr | Automated media management |
|
|
||||||
| Prowlarr + Bazarr | Indexer aggregation + subtitles |
|
|
||||||
| qBittorrent (via Gluetun) | Downloads — VPN-gated |
|
|
||||||
| Calibre-Web Automated | Book library with auto-ingest |
|
|
||||||
| Kavita | E-reader |
|
|
||||||
|
|
||||||
## DMZ (VLAN 1099 — 10.99.0.0/24)
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Vaultwarden | Bitwarden-compatible password manager. **Planned, not deployed yet.** |
|
||||||
|
|
||||||
| Service | URL | Status | Notes |
|
## Bots & automation
|
||||||
|---------|-----|--------|-------|
|
|
||||||
| Caddy (DMZ) | — | ✅ | Public reverse proxy |
|
|
||||||
| Gitea | https://gitea.lerkolabs.com | ✅ | Public Git |
|
|
||||||
| Portfolio | https://lerkolabs.com | ✅ | Personal site |
|
|
||||||
|
|
||||||
## Access Matrix
|
| Service | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Vocard | Discord music bot |
|
||||||
|
| MonitorRSS | RSS-to-Discord notifications |
|
||||||
|
| ntfy | Push notifications for ops alerts |
|
||||||
|
|
||||||
| Service | LAN | Homelab | Guest | IoT | WFH | VPN |
|
## Monitoring
|
||||||
|---------|-----|---------|-------|-----|-----|-----|
|
|
||||||
| pfSense Web GUI | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
| Service | What it does |
|
||||||
| Pi-hole Admin | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|---|---|
|
||||||
| All *.lerkolabs.com | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
| Victoria Metrics | Time-series store |
|
||||||
| Proxmox | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
| Grafana | Dashboards |
|
||||||
| Internet | ✅ | limited | ✅ | ✅ | ✅ | optional |
|
| 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.
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
# Apps LXC Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The `apps` LXC (10.2.0.60) in VLAN 1020 runs 15+ productivity apps via Docker Compose. All services run behind Authentik SSO (OIDC or forward auth). Shared infrastructure: single Postgres instance + single Redis instance, both local to the LXC. All services use `network_mode: host` to reach shared Postgres/Redis on localhost.
|
|
||||||
|
|
||||||
## LXC Spec
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Hostname | apps |
|
|
||||||
| IP | 10.2.0.60/24 |
|
|
||||||
| Gateway | 10.2.0.1 |
|
|
||||||
| DNS | 10.2.0.11 |
|
|
||||||
| Cores | 4 |
|
|
||||||
| RAM | 6GB |
|
|
||||||
| Disk | 80GB |
|
|
||||||
| Template | debian-12-standard |
|
|
||||||
| Nesting | ✓ (required for Docker) |
|
|
||||||
| Unprivileged | ✓ |
|
|
||||||
|
|
||||||
## Services
|
|
||||||
|
|
||||||
| Service | Port | Domain | DB | Auth |
|
|
||||||
|---------|------|--------|----|------|
|
|
||||||
| Outline | 3000 | outline.lerkolabs.com | Postgres | OIDC |
|
|
||||||
| Gitea | 3001 | gitea.lerkolabs.com | Postgres | OIDC |
|
|
||||||
| Vikunja | 3456 | tasks.lerkolabs.com | Postgres | OIDC |
|
|
||||||
| Ghostfolio | 3333 | finance.lerkolabs.com | Postgres | Forward auth |
|
|
||||||
| Hoarder | 3002 | hoarder.lerkolabs.com | Postgres | Forward auth |
|
|
||||||
| Grist | 8484 | grist.lerkolabs.com | SQLite | Forward auth |
|
|
||||||
| Glance | 8080 | glance.lerkolabs.com | — | Forward auth |
|
|
||||||
| Actual Budget | 5006 | budget.lerkolabs.com | File | Forward auth |
|
|
||||||
| FreshRSS | 8081 | rss.lerkolabs.com | SQLite | Forward auth |
|
|
||||||
| Memos | 5230 | memos.lerkolabs.com | SQLite | Forward auth |
|
|
||||||
| Traggo | 3030 | time.lerkolabs.com | SQLite | Forward auth |
|
|
||||||
| Baikal | 8082 | dav.lerkolabs.com | SQLite | Forward auth |
|
|
||||||
| Filebrowser | 8083 | files.lerkolabs.com | SQLite | Forward auth |
|
|
||||||
| Bytestash | 8084 | — | SQLite | Forward auth |
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- LXC created with nesting enabled before first start
|
|
||||||
- Authentik OIDC providers created for Outline, Gitea, Vikunja before starting those services
|
|
||||||
- Caddy Caddyfile updated with all service blocks (see Phase: Caddy)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt update && apt upgrade -y
|
|
||||||
apt install -y curl wget git nano ufw
|
|
||||||
timedatectl set-timezone <your/timezone>
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
systemctl enable docker
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /opt/docker/apps/{shared,outline,gitea,vikunja,ghostfolio,hoarder,grist,glance,actual,freshrss,memos,traggo,baikal,filebrowser,bytestash}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Shared Infrastructure (Postgres + Redis)
|
|
||||||
|
|
||||||
Start this first, before anything else.
|
|
||||||
|
|
||||||
### Shared .env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# /opt/docker/apps/.env
|
|
||||||
PG_ROOT_PASSWORD= # openssl rand -base64 32
|
|
||||||
REDIS_PASSWORD= # openssl rand -base64 24
|
|
||||||
PG_PASS_OUTLINE= # openssl rand -base64 24
|
|
||||||
PG_PASS_GITEA= # openssl rand -base64 24
|
|
||||||
PG_PASS_VIKUNJA= # openssl rand -base64 24
|
|
||||||
PG_PASS_GHOSTFOLIO= # openssl rand -base64 24
|
|
||||||
PG_PASS_HOARDER= # openssl rand -base64 24
|
|
||||||
PG_PASS_GRIST= # openssl rand -base64 24
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod 600 /opt/docker/apps/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### shared/docker-compose.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:<see private/configs/versions.md>
|
|
||||||
container_name: apps-postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: /opt/docker/apps/.env
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: ${PG_ROOT_PASSWORD}
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
volumes:
|
|
||||||
- ./pgdata:/var/lib/postgresql/data
|
|
||||||
- ./init:/docker-entrypoint-initdb.d
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:5432:5432"
|
|
||||||
networks:
|
|
||||||
- apps-net
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:<see private/configs/versions.md>
|
|
||||||
container_name: apps-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD} --save 60 1 --loglevel warning
|
|
||||||
env_file: /opt/docker/apps/.env
|
|
||||||
volumes:
|
|
||||||
- ./redisdata:/data
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:6379:6379"
|
|
||||||
networks:
|
|
||||||
- apps-net
|
|
||||||
|
|
||||||
networks:
|
|
||||||
apps-net:
|
|
||||||
name: apps-net
|
|
||||||
driver: bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database init script
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /opt/docker/apps/shared/init
|
|
||||||
```
|
|
||||||
|
|
||||||
`/opt/docker/apps/shared/init/01-create-databases.sql`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE USER outline WITH PASSWORD 'PG_PASS_OUTLINE_VALUE';
|
|
||||||
CREATE DATABASE outline OWNER outline;
|
|
||||||
|
|
||||||
CREATE USER gitea WITH PASSWORD 'PG_PASS_GITEA_VALUE';
|
|
||||||
CREATE DATABASE gitea OWNER gitea;
|
|
||||||
|
|
||||||
CREATE USER vikunja WITH PASSWORD 'PG_PASS_VIKUNJA_VALUE';
|
|
||||||
CREATE DATABASE vikunja OWNER vikunja;
|
|
||||||
|
|
||||||
CREATE USER ghostfolio WITH PASSWORD 'PG_PASS_GHOSTFOLIO_VALUE';
|
|
||||||
CREATE DATABASE ghostfolio OWNER ghostfolio;
|
|
||||||
|
|
||||||
CREATE USER hoarder WITH PASSWORD 'PG_PASS_HOARDER_VALUE';
|
|
||||||
CREATE DATABASE hoarder OWNER hoarder;
|
|
||||||
|
|
||||||
CREATE USER grist WITH PASSWORD 'PG_PASS_GRIST_VALUE';
|
|
||||||
CREATE DATABASE grist OWNER grist;
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace each `_VALUE` with the actual password from your .env. This script runs once on first `docker compose up`.
|
|
||||||
|
|
||||||
### Start shared infrastructure
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/apps/shared
|
|
||||||
docker compose --env-file /opt/docker/apps/.env up -d
|
|
||||||
docker compose logs -f # wait for "ready to accept connections"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec apps-postgres pg_isready -U postgres
|
|
||||||
docker exec apps-postgres psql -U postgres -c "\l"
|
|
||||||
# Should show: outline, gitea, vikunja, ghostfolio, hoarder, grist
|
|
||||||
```
|
|
||||||
|
|
||||||
## Startup Order
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Shared infrastructure always first
|
|
||||||
cd /opt/docker/apps/shared && docker compose up -d
|
|
||||||
sleep 15 # give postgres time on first run
|
|
||||||
|
|
||||||
# 2. Run Outline migrations before starting Outline
|
|
||||||
cd /opt/docker/apps/outline && docker compose run --rm outline-migrate
|
|
||||||
docker compose up -d outline
|
|
||||||
|
|
||||||
# 3. Everything else in parallel
|
|
||||||
for svc in gitea vikunja ghostfolio hoarder grist glance actual freshrss memos traggo baikal filebrowser bytestash; do
|
|
||||||
cd /opt/docker/apps/$svc && docker compose up -d
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
## Caddy Configuration
|
|
||||||
|
|
||||||
Add to Caddyfile on the infra LXC (10.2.0.20). Services with native OIDC don't need forward auth; others do.
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
# OIDC-native (no forward auth needed)
|
|
||||||
outline.lerkolabs.com {
|
|
||||||
reverse_proxy 10.2.0.60:3000
|
|
||||||
}
|
|
||||||
gitea.lerkolabs.com {
|
|
||||||
reverse_proxy 10.2.0.60:3001
|
|
||||||
}
|
|
||||||
tasks.lerkolabs.com {
|
|
||||||
reverse_proxy 10.2.0.60:3456
|
|
||||||
}
|
|
||||||
|
|
||||||
# Forward auth
|
|
||||||
finance.lerkolabs.com {
|
|
||||||
import authentik_forward_auth
|
|
||||||
reverse_proxy 10.2.0.60:3333
|
|
||||||
}
|
|
||||||
# ... repeat pattern for remaining services
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pi-hole DNS Records
|
|
||||||
|
|
||||||
All records point to 10.2.0.20 (Caddy), not 10.2.0.60 directly:
|
|
||||||
|
|
||||||
```
|
|
||||||
outline.lerkolabs.com → 10.2.0.20
|
|
||||||
gitea.lerkolabs.com → 10.2.0.20
|
|
||||||
tasks.lerkolabs.com → 10.2.0.20
|
|
||||||
finance.lerkolabs.com → 10.2.0.20
|
|
||||||
hoarder.lerkolabs.com → 10.2.0.20
|
|
||||||
grist.lerkolabs.com → 10.2.0.20
|
|
||||||
glance.lerkolabs.com → 10.2.0.20
|
|
||||||
budget.lerkolabs.com → 10.2.0.20
|
|
||||||
rss.lerkolabs.com → 10.2.0.20
|
|
||||||
memos.lerkolabs.com → 10.2.0.20
|
|
||||||
time.lerkolabs.com → 10.2.0.20
|
|
||||||
dav.lerkolabs.com → 10.2.0.20
|
|
||||||
files.lerkolabs.com → 10.2.0.20
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All containers running
|
|
||||||
docker ps --format "table {{.Names}}\t{{.Status}}" | sort
|
|
||||||
|
|
||||||
# Outline health
|
|
||||||
curl -s http://localhost:3000/api/health
|
|
||||||
|
|
||||||
# From LAN — check Authentik gate works
|
|
||||||
curl -I https://outline.lerkolabs.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Useful Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Logs for a service
|
|
||||||
docker logs -f outline
|
|
||||||
|
|
||||||
# Postgres: connect to a database
|
|
||||||
docker exec -it apps-postgres psql -U postgres -d outline
|
|
||||||
|
|
||||||
# Postgres: backup
|
|
||||||
docker exec apps-postgres pg_dump -U postgres outline > /opt/backups/outline-$(date +%Y%m%d).sql
|
|
||||||
|
|
||||||
# Disk usage by service
|
|
||||||
du -sh /opt/docker/apps/*/
|
|
||||||
```
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
# Authentik Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Authentik is the centralized identity provider for the entire homelab. It runs in the `auth` LXC (10.2.0.25) in VLAN 1020. It provides two auth mechanisms:
|
|
||||||
|
|
||||||
1. **Forward auth** — Caddy asks Authentik "is this person logged in?" before proxying any request. Services that don't support SSO natively get a login wall this way.
|
|
||||||
2. **OIDC provider** — Services that support OAuth2/OIDC (Outline, Gitea, Vikunja, Grafana, Proxmox) get true single sign-on.
|
|
||||||
|
|
||||||
Stack: Postgres + Authentik server + Authentik worker (Redis removed as of 2025.10).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- LXC at 10.2.0.25 with nesting enabled (Docker requires it)
|
|
||||||
- Caddy already deployed at 10.2.0.20
|
|
||||||
- Pi-hole DNS record: `auth.lerkolabs.com → 10.2.0.20`
|
|
||||||
|
|
||||||
## LXC Spec
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Hostname | auth |
|
|
||||||
| IP | 10.2.0.25/24 |
|
|
||||||
| Gateway | 10.2.0.1 |
|
|
||||||
| Cores | 2 |
|
|
||||||
| RAM | 2GB |
|
|
||||||
| Disk | 10GB |
|
|
||||||
| Template | debian-12-standard |
|
|
||||||
| Nesting | ✓ |
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt update && apt upgrade -y
|
|
||||||
apt install -y curl nano
|
|
||||||
timedatectl set-timezone <your/timezone>
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
systemctl enable docker
|
|
||||||
mkdir -p /opt/docker/authentik/{data,certs,custom-templates}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Generate secrets and create .env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" >> /opt/docker/authentik/.env
|
|
||||||
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" >> /opt/docker/authentik/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Add remaining config:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# PostgreSQL
|
|
||||||
PG_USER=authentik
|
|
||||||
PG_DB=authentik
|
|
||||||
|
|
||||||
# Pin to current version
|
|
||||||
AUTHENTIK_TAG=<see private/configs/versions.md>
|
|
||||||
|
|
||||||
AUTHENTIK_LOG_LEVEL=info
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod 600 /opt/docker/authentik/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### docker-compose.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
postgresql:
|
|
||||||
image: docker.io/library/postgres:<see private/configs/versions.md>
|
|
||||||
container_name: authentik-db
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -d ${PG_DB} -U ${PG_USER}"]
|
|
||||||
start_period: 20s
|
|
||||||
interval: 30s
|
|
||||||
retries: 5
|
|
||||||
timeout: 5s
|
|
||||||
volumes:
|
|
||||||
- ./postgres:/var/lib/postgresql/data
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: ${PG_PASS}
|
|
||||||
POSTGRES_USER: ${PG_USER}
|
|
||||||
POSTGRES_DB: ${PG_DB}
|
|
||||||
|
|
||||||
server:
|
|
||||||
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
|
|
||||||
container_name: authentik-server
|
|
||||||
restart: unless-stopped
|
|
||||||
command: server
|
|
||||||
environment:
|
|
||||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
|
||||||
AUTHENTIK_POSTGRESQL__HOST: postgresql
|
|
||||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER}
|
|
||||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
|
||||||
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB}
|
|
||||||
AUTHENTIK_LOG_LEVEL: ${AUTHENTIK_LOG_LEVEL:-info}
|
|
||||||
volumes:
|
|
||||||
- ./data:/data
|
|
||||||
- ./custom-templates:/templates
|
|
||||||
ports:
|
|
||||||
- "9000:9000"
|
|
||||||
- "9443:9443"
|
|
||||||
depends_on:
|
|
||||||
postgresql:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
worker:
|
|
||||||
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
|
|
||||||
container_name: authentik-worker
|
|
||||||
restart: unless-stopped
|
|
||||||
command: worker
|
|
||||||
environment:
|
|
||||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
|
||||||
AUTHENTIK_POSTGRESQL__HOST: postgresql
|
|
||||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER}
|
|
||||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
|
||||||
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB}
|
|
||||||
AUTHENTIK_LOG_LEVEL: ${AUTHENTIK_LOG_LEVEL:-info}
|
|
||||||
user: root
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./data:/data
|
|
||||||
- ./certs:/certs
|
|
||||||
- ./custom-templates:/templates
|
|
||||||
depends_on:
|
|
||||||
postgresql:
|
|
||||||
condition: service_healthy
|
|
||||||
```
|
|
||||||
|
|
||||||
## Start Authentik
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/authentik
|
|
||||||
docker compose up -d
|
|
||||||
docker logs -f authentik-server
|
|
||||||
# Wait for: "Everything is ready"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Initial Admin Setup
|
|
||||||
|
|
||||||
Navigate to: `http://10.2.0.25:9000/if/flow/initial-setup/` (include trailing slash)
|
|
||||||
|
|
||||||
Set admin password for the `akadmin` account. Save in Vaultwarden.
|
|
||||||
|
|
||||||
## Caddy Integration
|
|
||||||
|
|
||||||
Add to Caddyfile on the infra LXC (see [caddy.md](caddy.md)):
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
# Forward auth snippet
|
|
||||||
(authentik_forward_auth) {
|
|
||||||
forward_auth 10.2.0.25:9000 {
|
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
|
||||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email \
|
|
||||||
X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks \
|
|
||||||
X-Authentik-Meta-Outpost X-Authentik-Meta-Provider \
|
|
||||||
X-Authentik-Meta-App X-Authentik-Meta-Version
|
|
||||||
trusted_proxies private_ranges
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.lerkolabs.com {
|
|
||||||
reverse_proxy 10.2.0.25:9000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configure Outpost
|
|
||||||
|
|
||||||
In Authentik admin: Applications → Outposts → authentik Embedded Outpost → Edit
|
|
||||||
|
|
||||||
Set both:
|
|
||||||
- `authentik Host`: `https://auth.lerkolabs.com`
|
|
||||||
- `authentik Host (browser)`: `https://auth.lerkolabs.com`
|
|
||||||
|
|
||||||
## Adding Applications
|
|
||||||
|
|
||||||
### Forward auth pattern (services without native OIDC)
|
|
||||||
|
|
||||||
1. Applications → Providers → Create → Proxy Provider
|
|
||||||
- Mode: Forward auth (single application)
|
|
||||||
- External host: `https://<service>.lerkolabs.com`
|
|
||||||
2. Applications → Applications → Create
|
|
||||||
- Assign provider, set slug and launch URL
|
|
||||||
3. Outposts → Embedded Outpost → Edit → add application to Selected Applications
|
|
||||||
|
|
||||||
### OIDC pattern (Outline, Gitea, Vikunja)
|
|
||||||
|
|
||||||
1. Applications → Providers → Create → OAuth2/OpenID Provider
|
|
||||||
- Client type: Confidential
|
|
||||||
- Redirect URI: service-specific (see table below)
|
|
||||||
- Scopes: openid, email, profile
|
|
||||||
2. Note the Client ID and Client Secret — configure in the service's .env
|
|
||||||
|
|
||||||
| Service | Redirect URI |
|
|
||||||
|---------|-------------|
|
|
||||||
| Outline | `https://outline.lerkolabs.com/auth/oidc.callback` |
|
|
||||||
| Gitea | `https://gitea.lerkolabs.com/user/oauth2/authentik/callback` |
|
|
||||||
| Vikunja | `https://tasks.lerkolabs.com/auth/openid/authentik` |
|
|
||||||
| Grafana | `https://grafana.lerkolabs.com/login/generic_oauth` |
|
|
||||||
|
|
||||||
OIDC discovery URL pattern: `https://auth.lerkolabs.com/application/o/<slug>/.well-known/openid-configuration`
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All containers healthy
|
|
||||||
docker ps
|
|
||||||
# authentik-db Up X minutes (healthy)
|
|
||||||
# authentik-server Up X minutes
|
|
||||||
# authentik-worker Up X minutes
|
|
||||||
|
|
||||||
# Accessible via Caddy
|
|
||||||
curl -I https://auth.lerkolabs.com
|
|
||||||
# Expected: HTTP/2 302 (redirect to login)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Edit .env, bump AUTHENTIK_TAG to new version
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
-224
@@ -1,224 +0,0 @@
|
|||||||
# Caddy (infra LXC) Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The `infra` LXC (10.2.0.20) in VLAN 1020 runs Caddy as the internal reverse proxy. It handles TLS termination for all `*.lerkolabs.com` services using wildcard certs via Cloudflare DNS-01 challenge. Also hosts ntfy (push notifications) and Uptime Kuma (service monitoring) as co-located services.
|
|
||||||
|
|
||||||
| Service | Port | Domain |
|
|
||||||
|---------|------|--------|
|
|
||||||
| Caddy | 80/443 | reverse proxy — no direct domain |
|
|
||||||
| ntfy | 8090 | ntfy.lerkolabs.com |
|
|
||||||
| Uptime Kuma | 3001 | uptime.lerkolabs.com |
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- LXC created at 10.2.0.20 in VLAN 1020
|
|
||||||
- Cloudflare API token with Zone → DNS → Edit permissions for lerkolabs.com (stored in Vaultwarden)
|
|
||||||
- Pi-hole DNS record: `*.lerkolabs.com → 10.2.0.20`
|
|
||||||
|
|
||||||
## LXC Spec
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Hostname | infra |
|
|
||||||
| IP | 10.2.0.20/24 |
|
|
||||||
| Gateway | 10.2.0.1 |
|
|
||||||
| Cores | 2 |
|
|
||||||
| RAM | 1GB |
|
|
||||||
| Template | debian-12-standard |
|
|
||||||
| Nesting | ✓ (required for Docker) |
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt update && apt upgrade -y
|
|
||||||
apt install -y curl nano ufw
|
|
||||||
timedatectl set-timezone <your/timezone>
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
systemctl enable docker
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/opt/docker/
|
|
||||||
├── caddy/
|
|
||||||
│ ├── Caddyfile
|
|
||||||
│ ├── docker-compose.yml
|
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── .env
|
|
||||||
│ ├── data/
|
|
||||||
│ ├── config/
|
|
||||||
│ └── logs/
|
|
||||||
└── infra/
|
|
||||||
├── uptimekuma/
|
|
||||||
│ ├── docker-compose.yml
|
|
||||||
│ └── data/
|
|
||||||
└── ntfy/
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── server.yml
|
|
||||||
└── data/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Caddy Deployment
|
|
||||||
|
|
||||||
### Dockerfile (custom build with Cloudflare DNS plugin)
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM caddy:<see private/configs/versions.md> AS builder
|
|
||||||
RUN xcaddy build \
|
|
||||||
--with github.com/caddy-dns/cloudflare
|
|
||||||
|
|
||||||
FROM caddy:<see private/configs/versions.md>
|
|
||||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
|
||||||
```
|
|
||||||
|
|
||||||
### .env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CLOUDFLARE_API_TOKEN=<token from Vaultwarden: homelab/cloudflare-api>
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod 600 /opt/docker/caddy/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### docker-compose.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
build: .
|
|
||||||
container_name: caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: host
|
|
||||||
volumes:
|
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
||||||
- ./data:/data
|
|
||||||
- ./config:/config
|
|
||||||
- ./logs:/logs
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### Caddyfile structure
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
{
|
|
||||||
email <your-acme-email>
|
|
||||||
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Forward auth snippet — reuse in every protected service block
|
|
||||||
(authentik_forward_auth) {
|
|
||||||
forward_auth 10.2.0.25:9000 {
|
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
|
||||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email \
|
|
||||||
X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks \
|
|
||||||
X-Authentik-Meta-Outpost X-Authentik-Meta-Provider \
|
|
||||||
X-Authentik-Meta-App X-Authentik-Meta-Version
|
|
||||||
trusted_proxies private_ranges
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Authentik
|
|
||||||
auth.lerkolabs.com {
|
|
||||||
reverse_proxy 10.2.0.25:9000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Pi-hole (forward auth)
|
|
||||||
pihole.lerkolabs.com {
|
|
||||||
import authentik_forward_auth
|
|
||||||
reverse_proxy 10.2.0.11:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add remaining services following the same pattern
|
|
||||||
# Services with native OIDC (Outline, Gitea, Vikunja): no forward auth needed
|
|
||||||
# Services without OIDC: import authentik_forward_auth
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build and start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/caddy
|
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
|
||||||
docker logs -f caddy
|
|
||||||
# Wait for: "certificate obtained successfully"
|
|
||||||
```
|
|
||||||
|
|
||||||
## ntfy Deployment
|
|
||||||
|
|
||||||
### server.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
base-url: https://ntfy.lerkolabs.com
|
|
||||||
listen-http: ":8090"
|
|
||||||
cache-file: /var/cache/ntfy/cache.db
|
|
||||||
auth-file: /var/lib/ntfy/auth.db
|
|
||||||
auth-default-access: deny-all
|
|
||||||
behind-proxy: true
|
|
||||||
```
|
|
||||||
|
|
||||||
### docker-compose.yml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
ntfy:
|
|
||||||
image: binwiederhier/ntfy:latest
|
|
||||||
container_name: ntfy
|
|
||||||
restart: unless-stopped
|
|
||||||
command: serve
|
|
||||||
ports:
|
|
||||||
- "8090:8090"
|
|
||||||
volumes:
|
|
||||||
- ./server.yml:/etc/ntfy/server.yml:ro
|
|
||||||
- ./data:/var/cache/ntfy
|
|
||||||
- ./data:/var/lib/ntfy
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/infra/ntfy && docker compose up -d
|
|
||||||
docker exec -it ntfy ntfy user add --role=admin <username>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Uptime Kuma Deployment
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
uptime-kuma:
|
|
||||||
image: louislam/uptime-kuma:latest
|
|
||||||
container_name: uptime-kuma
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/infra/uptimekuma && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Caddy Reload
|
|
||||||
|
|
||||||
After editing the Caddyfile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
|
|
||||||
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Cert issued
|
|
||||||
docker logs caddy | grep "certificate obtained"
|
|
||||||
|
|
||||||
# Service reachable
|
|
||||||
curl -I https://pihole.lerkolabs.com
|
|
||||||
# Expected: HTTP/2 200
|
|
||||||
|
|
||||||
# Ports bound
|
|
||||||
ss -tlnp | grep -E "443|8090|3001"
|
|
||||||
```
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Monitor LXC Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The `monitor` LXC (10.2.0.51) in VLAN 1020 runs the full observability stack: Victoria Metrics (metrics storage), Grafana (dashboards and alerting), and Beszel (container + host monitoring). All services run via Docker Compose.
|
|
||||||
|
|
||||||
## LXC Spec
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Hostname | monitor |
|
|
||||||
| IP | 10.2.0.51/24 |
|
|
||||||
| Gateway | 10.2.0.1 |
|
|
||||||
| DNS | 10.2.0.11 |
|
|
||||||
| Cores | 4 |
|
|
||||||
| RAM | 4GB |
|
|
||||||
| Template | debian-12-standard |
|
|
||||||
| Nesting | ✓ |
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Caddy running at 10.2.0.20
|
|
||||||
- Pi-hole DNS records added (see Verification)
|
|
||||||
- Beszel agents deployed on all LXCs to be monitored
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt update && apt upgrade -y
|
|
||||||
apt install -y curl nano
|
|
||||||
timedatectl set-timezone <your/timezone>
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
systemctl enable docker
|
|
||||||
mkdir -p /opt/docker/monitor/{victoria-metrics,grafana,beszel}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Victoria Metrics
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# /opt/docker/monitor/victoria-metrics/docker-compose.yml
|
|
||||||
services:
|
|
||||||
victoria-metrics:
|
|
||||||
image: victoriametrics/victoria-metrics:latest
|
|
||||||
container_name: victoria-metrics
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8428:8428"
|
|
||||||
volumes:
|
|
||||||
- ./data:/storage
|
|
||||||
command:
|
|
||||||
- "--storageDataPath=/storage"
|
|
||||||
- "--retentionPeriod=90d"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/monitor/victoria-metrics && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Grafana
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# /opt/docker/monitor/grafana/docker-compose.yml
|
|
||||||
services:
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
container_name: grafana
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/var/lib/grafana
|
|
||||||
environment:
|
|
||||||
GF_SERVER_ROOT_URL: https://grafana.lerkolabs.com
|
|
||||||
GF_AUTH_GENERIC_OAUTH_ENABLED: "true"
|
|
||||||
GF_AUTH_GENERIC_OAUTH_NAME: Authentik
|
|
||||||
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: <from Authentik OIDC provider>
|
|
||||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: <from Authentik OIDC provider>
|
|
||||||
GF_AUTH_GENERIC_OAUTH_SCOPES: openid email profile
|
|
||||||
GF_AUTH_GENERIC_OAUTH_AUTH_URL: https://auth.lerkolabs.com/application/o/authorize/
|
|
||||||
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: https://auth.lerkolabs.com/application/o/token/
|
|
||||||
GF_AUTH_GENERIC_OAUTH_API_URL: https://auth.lerkolabs.com/application/o/userinfo/
|
|
||||||
GF_AUTH_SIGNOUT_REDIRECT_URL: https://auth.lerkolabs.com/application/o/grafana/end-session/
|
|
||||||
GF_AUTH_OAUTH_AUTO_LOGIN: "true"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/monitor/grafana && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Add Victoria Metrics as a data source in Grafana: `http://localhost:8428`
|
|
||||||
|
|
||||||
## Beszel
|
|
||||||
|
|
||||||
Beszel hub runs on the monitor LXC. Beszel agents run on each LXC/VM being monitored.
|
|
||||||
|
|
||||||
### Hub (monitor LXC)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# /opt/docker/monitor/beszel/docker-compose.yml
|
|
||||||
services:
|
|
||||||
beszel:
|
|
||||||
image: henrygd/beszel:latest
|
|
||||||
container_name: beszel
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8090:8090"
|
|
||||||
volumes:
|
|
||||||
- ./data:/beszel_data
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/monitor/beszel && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agents (each LXC)
|
|
||||||
|
|
||||||
On each LXC that needs monitoring:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh
|
|
||||||
chmod +x install-agent.sh
|
|
||||||
./install-agent.sh # follow prompts, enter hub address and key
|
|
||||||
```
|
|
||||||
|
|
||||||
## Caddy Configuration
|
|
||||||
|
|
||||||
Add to Caddyfile on infra LXC:
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
grafana.lerkolabs.com {
|
|
||||||
reverse_proxy 10.2.0.51:3000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Beszel and Victoria Metrics are internal-only (no public Caddy entries needed unless you want external access).
|
|
||||||
|
|
||||||
## Pi-hole DNS Records
|
|
||||||
|
|
||||||
```
|
|
||||||
grafana.lerkolabs.com → 10.2.0.20
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All containers running
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# Victoria Metrics health
|
|
||||||
curl http://localhost:8428/health
|
|
||||||
|
|
||||||
# Grafana reachable
|
|
||||||
curl -I https://grafana.lerkolabs.com
|
|
||||||
|
|
||||||
# Beszel agents reporting
|
|
||||||
# Check Beszel web UI at http://10.2.0.51:8090
|
|
||||||
```
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# pfSense VLAN Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
pfSense (Intel N100 mini PC at 10.0.0.1 / 10.1.0.1) handles firewall, routing, DHCP, DNS resolution, and WireGuard VPN for all 8 VLANs. See [Network](../docs/NETWORK.md) for the full VLAN map and firewall policy. See [Decisions](../docs/DECISIONS.md) D005 for the AT&T IP Passthrough rationale.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- pfSense installed on Intel N100 mini PC
|
|
||||||
- AT&T BGW320 in IP Passthrough mode (pfSense WAN gets public IP)
|
|
||||||
- Omada managed switch connected to pfSense
|
|
||||||
- Trunk port between pfSense and switch carrying all VLANs tagged
|
|
||||||
|
|
||||||
## VLAN Configuration
|
|
||||||
|
|
||||||
### 1. Create VLAN Interfaces
|
|
||||||
|
|
||||||
Navigate to: **Interfaces → VLANs → Add**
|
|
||||||
|
|
||||||
Create one entry per VLAN:
|
|
||||||
|
|
||||||
| VLAN Tag | Parent | Description |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| 1000 | (WAN NIC or LAN NIC) | MGMT |
|
|
||||||
| 1010 | (LAN NIC) | LAN |
|
|
||||||
| 1020 | (LAN NIC) | Homelab |
|
|
||||||
| 1030 | (LAN NIC) | Guests |
|
|
||||||
| 1040 | (LAN NIC) | IoT |
|
|
||||||
| 1050 | (LAN NIC) | WFH |
|
|
||||||
| 1099 | (LAN NIC) | DMZ |
|
|
||||||
|
|
||||||
### 2. Assign VLAN Interfaces
|
|
||||||
|
|
||||||
Navigate to: **Interfaces → Assignments**
|
|
||||||
|
|
||||||
Add each VLAN as a new interface. Enable and configure each:
|
|
||||||
|
|
||||||
| Interface | IP | Subnet |
|
|
||||||
|-----------|-----|--------|
|
|
||||||
| MGMT (1000) | 10.0.0.1 | /24 |
|
|
||||||
| LAN (1010) | 10.1.0.1 | /24 |
|
|
||||||
| Homelab (1020) | 10.2.0.1 | /24 |
|
|
||||||
| Guests (1030) | 10.3.0.1 | /24 |
|
|
||||||
| IoT (1040) | 10.4.0.1 | /24 |
|
|
||||||
| WFH (1050) | 10.5.0.1 | /24 |
|
|
||||||
| DMZ (1099) | 10.99.0.1 | /24 |
|
|
||||||
|
|
||||||
### 3. DHCP Servers
|
|
||||||
|
|
||||||
Navigate to: **Services → DHCP Server** — configure one per VLAN:
|
|
||||||
|
|
||||||
| VLAN | DHCP Range | DNS |
|
|
||||||
|------|------------|-----|
|
|
||||||
| MGMT | 10.0.0.100–150 | pfSense (10.0.0.1) |
|
|
||||||
| LAN | 10.1.0.100–200 | Pi-hole (10.2.0.11) |
|
|
||||||
| Homelab | 10.2.0.100–200 | Pi-hole (10.2.0.11) |
|
|
||||||
| Guests | 10.3.0.100–250 | Pi-hole (10.2.0.11) |
|
|
||||||
| IoT | 10.4.0.100–250 | Pi-hole (10.2.0.11) |
|
|
||||||
| WFH | 10.5.0.100–200 | pfSense (10.5.0.1) — Pi-hole intentionally excluded |
|
|
||||||
| DMZ | static only | pfSense (10.99.0.1) |
|
|
||||||
|
|
||||||
### 4. Firewall Rules
|
|
||||||
|
|
||||||
Navigate to: **Firewall → Rules** — configure per-interface rules following the policy in [NETWORK.md](../docs/NETWORK.md#firewall-policy).
|
|
||||||
|
|
||||||
Key rules:
|
|
||||||
|
|
||||||
- Default deny all inter-VLAN (floating rule or per-interface block at end)
|
|
||||||
- LAN → Homelab: allow (LAN users reach services)
|
|
||||||
- LAN → MGMT: allow (admin access from home devices)
|
|
||||||
- Homelab → internet: HTTP/S, SSH, NTP only (for updates)
|
|
||||||
- Guests → internet only: block all RFC1918
|
|
||||||
- IoT → internet + Home Assistant: block everything else
|
|
||||||
- WFH → internet only: block all RFC1918, pfSense DNS only
|
|
||||||
- MGMT → internet: NTP + updates only; inbound from LAN + VPN only
|
|
||||||
- DMZ → internet: HTTP/S + NTP; block all internal VLANs
|
|
||||||
|
|
||||||
### 5. DNS Resolver (Unbound)
|
|
||||||
|
|
||||||
Navigate to: **Services → DNS Resolver**
|
|
||||||
|
|
||||||
- Enable: ✓
|
|
||||||
- Listen on: all interfaces
|
|
||||||
- Upstream DNS: Cloudflare 1.1.1.1
|
|
||||||
- DNSSEC: ✓ (optional)
|
|
||||||
|
|
||||||
Pi-hole (10.2.0.11) uses pfSense Unbound as its upstream. WFH VLAN devices use pfSense Unbound directly — Pi-hole is unreachable from WFH by firewall rule.
|
|
||||||
|
|
||||||
### 6. Static DHCP Reservations
|
|
||||||
|
|
||||||
Navigate to: **Services → DHCP Server → [interface] → DHCP Static Mappings**
|
|
||||||
|
|
||||||
Add reservations for all homelab hosts from [NETWORK.md](../docs/NETWORK.md#static-ip-reservations).
|
|
||||||
|
|
||||||
## Configuration Backup
|
|
||||||
|
|
||||||
Navigate to: **Diagnostics → Backup & Restore → Backup Configuration**
|
|
||||||
|
|
||||||
Download `config.xml`. Store in Vaultwarden or PBS. This is the single file needed to restore pfSense from scratch.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From a LAN device:
|
|
||||||
# 1. Gets IP from DHCP in 10.1.0.100–200 range
|
|
||||||
ip addr
|
|
||||||
|
|
||||||
# 2. DNS resolves via Pi-hole
|
|
||||||
nslookup google.com # should show answer from 10.2.0.11
|
|
||||||
|
|
||||||
# 3. Internal service resolves
|
|
||||||
nslookup outline.lerkolabs.com # should return 10.2.0.20
|
|
||||||
|
|
||||||
# 4. Internet access works
|
|
||||||
curl -I https://google.com
|
|
||||||
```
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Pi-hole Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Pi-hole runs in the `pihole` LXC (10.2.0.11) in VLAN 1020 (Homelab). It is the primary DNS server for all VLANs, providing ad/tracker blocking, local DNS records, and query logging. All `*.lerkolabs.com` subdomains resolve to 10.2.0.20 (Caddy). Upstream resolver is pfSense Unbound → Cloudflare 1.1.1.1.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- LXC created in VLAN 1020 with static IP 10.2.0.11
|
|
||||||
- Debian 12 template
|
|
||||||
- pfSense DHCP reservations updated to point VLANs at 10.2.0.11 for DNS
|
|
||||||
|
|
||||||
## LXC Spec
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Hostname | pihole |
|
|
||||||
| IP | 10.2.0.11/24 |
|
|
||||||
| Gateway | 10.2.0.1 |
|
|
||||||
| Cores | 1 |
|
|
||||||
| RAM | 512MB |
|
|
||||||
| Template | debian-12-standard |
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt update && apt upgrade -y
|
|
||||||
curl -sSL https://install.pi-hole.net | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Installer prompts:
|
|
||||||
- Upstream DNS: Custom (set to pfSense: 10.2.0.1)
|
|
||||||
- Blocklists: Default (customize later)
|
|
||||||
- Admin Web Interface: Yes
|
|
||||||
- Web Server: lighttpd
|
|
||||||
- Query Logging: Yes
|
|
||||||
- Privacy Mode: Show everything (0)
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Local DNS Records
|
|
||||||
|
|
||||||
Add all internal domains via **Local DNS → DNS Records**. Every entry points to 10.2.0.20 (Caddy), not the service directly.
|
|
||||||
|
|
||||||
Key records to add:
|
|
||||||
|
|
||||||
| Domain | IP |
|
|
||||||
|--------|----|
|
|
||||||
| pihole.lerkolabs.com | 10.2.0.20 |
|
|
||||||
| auth.lerkolabs.com | 10.2.0.20 |
|
|
||||||
| outline.lerkolabs.com | 10.2.0.20 |
|
|
||||||
| gitea.lerkolabs.com | 10.2.0.20 |
|
|
||||||
| tasks.lerkolabs.com | 10.2.0.20 |
|
|
||||||
| finance.lerkolabs.com | 10.2.0.20 |
|
|
||||||
| grafana.lerkolabs.com | 10.2.0.20 |
|
|
||||||
| proxmox.lerkolabs.com | 10.2.0.20 |
|
|
||||||
| vault.lerkolabs.com | 10.2.0.20 |
|
|
||||||
|
|
||||||
Add remaining services from [SERVICES.md](../docs/SERVICES.md) following the same pattern.
|
|
||||||
|
|
||||||
### Upstream DNS
|
|
||||||
|
|
||||||
Settings → DNS → Custom upstream: `10.2.0.1` (pfSense Unbound)
|
|
||||||
|
|
||||||
Uncheck all other upstream providers.
|
|
||||||
|
|
||||||
### pfSense DHCP Integration
|
|
||||||
|
|
||||||
In pfSense: set DNS server for each VLAN's DHCP scope to 10.2.0.11. The WFH VLAN (1050) is the exception — it uses pfSense DNS only (Pi-hole unreachable by design).
|
|
||||||
|
|
||||||
## Backup / Restore
|
|
||||||
|
|
||||||
Use Teleporter for full config export: Settings → Teleporter → Backup. Store the teleporter zip in Vaultwarden or PBS.
|
|
||||||
|
|
||||||
On restore: Settings → Teleporter → Restore. All DNS records, blocklists, and settings are included.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# DNS resolves internal names
|
|
||||||
nslookup outline.lerkolabs.com 10.2.0.11
|
|
||||||
# Expected: 10.2.0.20
|
|
||||||
|
|
||||||
# Ad blocking active
|
|
||||||
nslookup doubleclick.net 10.2.0.11
|
|
||||||
# Expected: 0.0.0.0
|
|
||||||
|
|
||||||
# Admin interface
|
|
||||||
curl -s http://10.2.0.11/admin | grep -i pi-hole
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pihole -up
|
|
||||||
```
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# Servarr (Media VM) Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The `servarr` VM runs the complete media stack: Plex and Jellyfin for streaming, the *arr suite for automated media management, and qBittorrent routed through Gluetun VPN for downloads. All services run via Docker Compose. The VM lives on Proxmox in VLAN 1020 (Homelab).
|
|
||||||
|
|
||||||
## VM Spec
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Hostname | servarr |
|
|
||||||
| VLAN | 1020 (Homelab) |
|
|
||||||
| Cores | 4 |
|
|
||||||
| RAM | 8GB |
|
|
||||||
| OS | Debian 12 |
|
|
||||||
| Nesting | ✓ |
|
|
||||||
|
|
||||||
## Services
|
|
||||||
|
|
||||||
| Service | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| Plex | Media streaming (hardware transcoding) |
|
|
||||||
| Jellyfin | Open-source media streaming alternative |
|
|
||||||
| Sonarr | TV show management |
|
|
||||||
| Radarr | Movie management |
|
|
||||||
| Lidarr | Music management |
|
|
||||||
| Prowlarr | Indexer aggregation (feeds Sonarr/Radarr/Lidarr) |
|
|
||||||
| Bazarr | Subtitle management |
|
|
||||||
| qBittorrent | Downloads — routed through Gluetun VPN container |
|
|
||||||
| Calibre-Web Automated | Book library with auto-ingest |
|
|
||||||
| Kavita | E-reader / comic reader |
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- VM created in VLAN 1020
|
|
||||||
- Gluetun-compatible VPN credentials (stored in Vaultwarden)
|
|
||||||
- Media storage mounted (NFS or local disk)
|
|
||||||
- Caddy routing configured for any public-facing services
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt update && apt upgrade -y
|
|
||||||
apt install -y curl nano
|
|
||||||
timedatectl set-timezone <your/timezone>
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
systemctl enable docker
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/opt/docker/servarr/
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── .env
|
|
||||||
└── config/
|
|
||||||
├── plex/
|
|
||||||
├── jellyfin/
|
|
||||||
├── sonarr/
|
|
||||||
├── radarr/
|
|
||||||
├── lidarr/
|
|
||||||
├── prowlarr/
|
|
||||||
├── bazarr/
|
|
||||||
├── qbittorrent/
|
|
||||||
├── calibre/
|
|
||||||
└── kavita/
|
|
||||||
|
|
||||||
/media/
|
|
||||||
├── tv/
|
|
||||||
├── movies/
|
|
||||||
├── music/
|
|
||||||
├── books/
|
|
||||||
└── downloads/
|
|
||||||
├── complete/
|
|
||||||
└── incomplete/
|
|
||||||
```
|
|
||||||
|
|
||||||
## qBittorrent + Gluetun (VPN-gated downloads)
|
|
||||||
|
|
||||||
qBittorrent runs inside the Gluetun network namespace. All download traffic exits through the VPN — no VPN = no download traffic.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml excerpt
|
|
||||||
services:
|
|
||||||
gluetun:
|
|
||||||
image: qmcgaw/gluetun:latest
|
|
||||||
container_name: gluetun
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
environment:
|
|
||||||
- VPN_SERVICE_PROVIDER=<provider>
|
|
||||||
- VPN_TYPE=wireguard
|
|
||||||
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
|
|
||||||
- WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
|
|
||||||
- SERVER_COUNTRIES=${SERVER_COUNTRIES}
|
|
||||||
ports:
|
|
||||||
- "8080:8080" # qBittorrent WebUI via Gluetun
|
|
||||||
|
|
||||||
qbittorrent:
|
|
||||||
image: lscr.io/linuxserver/qbittorrent:latest
|
|
||||||
container_name: qbittorrent
|
|
||||||
network_mode: "service:gluetun" # all traffic through VPN
|
|
||||||
environment:
|
|
||||||
- WEBUI_PORT=8080
|
|
||||||
volumes:
|
|
||||||
- ./config/qbittorrent:/config
|
|
||||||
- /media/downloads:/downloads
|
|
||||||
```
|
|
||||||
|
|
||||||
## *arr Suite Configuration
|
|
||||||
|
|
||||||
Prowlarr is the central indexer — configure it first, then connect Sonarr/Radarr/Lidarr to it. All *arr services connect to qBittorrent as the download client (pointing to Gluetun's exposed port).
|
|
||||||
|
|
||||||
## Caddy Configuration
|
|
||||||
|
|
||||||
Add to Caddyfile on infra LXC for any *arr services you want accessible via HTTPS. Replace `<servarr-ip>` with the VM's IP.
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
# Example — Plex handles its own auth, no forward auth needed
|
|
||||||
plex.lerkolabs.com {
|
|
||||||
reverse_proxy <servarr-ip>:32400
|
|
||||||
}
|
|
||||||
|
|
||||||
# *arr services — protect with Authentik forward auth
|
|
||||||
sonarr.lerkolabs.com {
|
|
||||||
import authentik_forward_auth
|
|
||||||
reverse_proxy <servarr-ip>:8989
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All containers running
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# Gluetun VPN tunnel active
|
|
||||||
docker exec gluetun wget -qO- https://api.ipinfo.io/ip
|
|
||||||
# Should return VPN provider IP, not home WAN IP
|
|
||||||
|
|
||||||
# qBittorrent accessible
|
|
||||||
curl -I http://localhost:8080
|
|
||||||
```
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Vaultwarden Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Vaultwarden runs in the `vault` LXC (10.2.0.X) in VLAN 1020 (Homelab). It is isolated — no shared containers, no shared Postgres. Accessible at `https://vault.lerkolabs.com` via Caddy with Authentik forward auth. VPN-only access (not exposed to internet directly).
|
|
||||||
|
|
||||||
## LXC Spec
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Hostname | vault |
|
|
||||||
| IP | 10.2.0.X/24 (TBD) |
|
|
||||||
| Gateway | 10.2.0.1 |
|
|
||||||
| DNS | 10.2.0.11 |
|
|
||||||
| Cores | 1 |
|
|
||||||
| RAM | 256MB |
|
|
||||||
| Disk | 4GB |
|
|
||||||
| Template | debian-12-standard |
|
|
||||||
| Nesting | ✓ |
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Caddy running at 10.2.0.20
|
|
||||||
- Pi-hole DNS record: `vault.lerkolabs.com → 10.2.0.20`
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt update && apt upgrade -y
|
|
||||||
apt install -y curl nano
|
|
||||||
timedatectl set-timezone <your/timezone>
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
systemctl enable docker
|
|
||||||
mkdir -p /opt/docker/vaultwarden/data
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# /opt/docker/vaultwarden/docker-compose.yml
|
|
||||||
services:
|
|
||||||
vaultwarden:
|
|
||||||
image: vaultwarden/server:latest
|
|
||||||
container_name: vaultwarden
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
volumes:
|
|
||||||
- ./data:/data
|
|
||||||
environment:
|
|
||||||
- DOMAIN=https://vault.lerkolabs.com
|
|
||||||
- SIGNUPS_ALLOWED=true # set false after creating your account
|
|
||||||
- WEBSOCKET_ENABLED=true
|
|
||||||
- LOG_FILE=/data/vaultwarden.log
|
|
||||||
- LOG_LEVEL=warn
|
|
||||||
- ROCKET_PORT=80
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/vaultwarden
|
|
||||||
docker compose up -d
|
|
||||||
docker logs -f vaultwarden
|
|
||||||
```
|
|
||||||
|
|
||||||
## Initial Account Setup
|
|
||||||
|
|
||||||
1. Navigate to `https://vault.lerkolabs.com`
|
|
||||||
2. Create your account
|
|
||||||
3. Set `SIGNUPS_ALLOWED=false` in docker-compose.yml and restart:
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Enable Admin Panel
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -base64 48 # generate admin token
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to environment in docker-compose.yml:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- ADMIN_TOKEN=<generated_token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Access admin panel at: `https://vault.lerkolabs.com/admin`
|
|
||||||
|
|
||||||
## Caddy Configuration
|
|
||||||
|
|
||||||
Add to Caddyfile on infra LXC:
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
vault.lerkolabs.com {
|
|
||||||
import authentik_forward_auth
|
|
||||||
reverse_proxy 10.2.0.X:80
|
|
||||||
header {
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Referrer-Policy "no-referrer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connecting Bitwarden Clients
|
|
||||||
|
|
||||||
In any official Bitwarden client (mobile, desktop, browser extension):
|
|
||||||
|
|
||||||
```
|
|
||||||
Settings → Self-hosted Environment
|
|
||||||
Server URL: https://vault.lerkolabs.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# /opt/backup-vaultwarden.sh
|
|
||||||
BACKUP_DIR="/opt/backups/vaultwarden"
|
|
||||||
DATE=$(date +%Y%m%d-%H%M%S)
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
|
|
||||||
docker stop vaultwarden
|
|
||||||
tar -czf "$BACKUP_DIR/vaultwarden-$DATE.tar.gz" /opt/docker/vaultwarden/data/
|
|
||||||
docker start vaultwarden
|
|
||||||
|
|
||||||
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x /opt/backup-vaultwarden.sh
|
|
||||||
crontab -e
|
|
||||||
# Add: 0 2 * * * /opt/backup-vaultwarden.sh >> /var/log/vaultwarden-backup.log 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Container running
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# Accessible via Caddy
|
|
||||||
curl -I https://vault.lerkolabs.com
|
|
||||||
# Expected: HTTP/2 200 or 302 (Authentik redirect)
|
|
||||||
|
|
||||||
# Data directory exists
|
|
||||||
ls /opt/docker/vaultwarden/data/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/docker/vaultwarden
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
docker image prune -f
|
|
||||||
```
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# WireGuard Setup
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
WireGuard VPN is configured directly in pfSense. It runs on UDP port 51820 — the only inbound port on the WAN interface. VPN clients get IPs in the 10.200.0.0/24 subnet and receive the same network access as LAN (Homelab + MGMT web GUI + Pi-hole DNS). No external software needed — pfSense handles it natively.
|
|
||||||
|
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
|
||||||
| Listen Port | 51820 UDP |
|
|
||||||
| VPN Subnet | 10.200.0.0/24 |
|
|
||||||
| Access granted | Homelab (10.2.0.0/24) + MGMT web GUI + Pi-hole DNS |
|
|
||||||
| Access blocked | Guest, IoT, WFH VLANs |
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- pfSense running and accessible
|
|
||||||
- WireGuard package installed (System → Package Manager → Available Packages → WireGuard)
|
|
||||||
- Port 51820 UDP forwarded/open on WAN if behind NAT (not needed with IP Passthrough — pfSense has the public IP directly)
|
|
||||||
- DDNS client configured on pfSense if WAN IP is dynamic
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### 1. Install WireGuard Package
|
|
||||||
|
|
||||||
Navigate to: **System → Package Manager → Available Packages**
|
|
||||||
|
|
||||||
Search "WireGuard" → Install.
|
|
||||||
|
|
||||||
### 2. Create WireGuard Tunnel
|
|
||||||
|
|
||||||
Navigate to: **VPN → WireGuard → Tunnels → Add Tunnel**
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| Enabled | ✓ |
|
|
||||||
| Description | HomeVPN |
|
|
||||||
| Listen Port | 51820 |
|
|
||||||
| Interface Keys | Click "Generate" |
|
|
||||||
| Interface Addresses | 10.200.0.1/24 |
|
|
||||||
|
|
||||||
Save. Note the **server public key** — you'll need it in peer configs.
|
|
||||||
|
|
||||||
### 3. Add Peers (Clients)
|
|
||||||
|
|
||||||
Navigate to: **VPN → WireGuard → Peers → Add Peer**
|
|
||||||
|
|
||||||
For each client device:
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| Tunnel | HomeVPN |
|
|
||||||
| Description | e.g., iPhone |
|
|
||||||
| Public Key | (generate on client, paste here) |
|
|
||||||
| Allowed IPs | 10.200.0.X/32 (unique per peer) |
|
|
||||||
|
|
||||||
### 4. Create WireGuard Interface
|
|
||||||
|
|
||||||
Navigate to: **Interfaces → Assignments**
|
|
||||||
|
|
||||||
Assign the WireGuard tunnel as a new interface (e.g., `OPT1`). Rename it to `WG` or `VPN`.
|
|
||||||
|
|
||||||
Enable the interface: Interfaces → WG → Enable ✓
|
|
||||||
|
|
||||||
### 5. Firewall Rules
|
|
||||||
|
|
||||||
#### WAN — allow inbound WireGuard
|
|
||||||
|
|
||||||
Navigate to: **Firewall → Rules → WAN → Add**
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| Action | Pass |
|
|
||||||
| Protocol | UDP |
|
|
||||||
| Destination | WAN address |
|
|
||||||
| Destination Port | 51820 |
|
|
||||||
| Description | WireGuard VPN |
|
|
||||||
|
|
||||||
#### WG interface — allow VPN clients same access as LAN
|
|
||||||
|
|
||||||
Navigate to: **Firewall → Rules → WG → Add**
|
|
||||||
|
|
||||||
```
|
|
||||||
Pass | IPv4 | Source: WG net | Destination: 10.2.0.0/24 | any | Homelab access
|
|
||||||
Pass | IPv4 | Source: WG net | Destination: 10.0.0.0/24 | 443 | MGMT web GUI
|
|
||||||
Pass | IPv4 | Source: WG net | Destination: 10.2.0.11 | 53 | Pi-hole DNS
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. DNS for VPN Clients
|
|
||||||
|
|
||||||
In WireGuard peer config, set DNS to 10.2.0.11 (Pi-hole) so VPN clients get ad blocking and local name resolution.
|
|
||||||
|
|
||||||
## Client Configuration
|
|
||||||
|
|
||||||
Generate on each client device. Structure:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Interface]
|
|
||||||
PrivateKey = <client private key>
|
|
||||||
Address = 10.200.0.X/24
|
|
||||||
DNS = 10.2.0.11
|
|
||||||
|
|
||||||
[Peer]
|
|
||||||
PublicKey = <server public key from pfSense>
|
|
||||||
Endpoint = <WAN IP or DDNS hostname>:51820
|
|
||||||
AllowedIPs = 10.0.0.0/8 # route all RFC1918 through VPN, or use split tunnel
|
|
||||||
PersistentKeepalive = 25
|
|
||||||
```
|
|
||||||
|
|
||||||
In pfSense you can generate QR codes for mobile clients: VPN → WireGuard → Peers → (peer) → QR code icon.
|
|
||||||
|
|
||||||
## Key Rotation
|
|
||||||
|
|
||||||
When rotating keys or adding/removing peers:
|
|
||||||
|
|
||||||
1. Generate new key pair on client
|
|
||||||
2. Update peer's public key in pfSense: VPN → WireGuard → Peers → Edit
|
|
||||||
3. Update client config with new private key
|
|
||||||
4. Apply changes in pfSense
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From a mobile device on cellular (not home WiFi):
|
|
||||||
# 1. Connect WireGuard
|
|
||||||
# 2. curl https://outline.lerkolabs.com → should load with Authentik login
|
|
||||||
# 3. curl http://10.2.0.11/admin → Pi-hole admin should be reachable
|
|
||||||
|
|
||||||
# On pfSense shell:
|
|
||||||
wg show # should show peer with recent handshake
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user