Design Guidelines¶
Architectural decisions and the reasoning behind them.
Why GitOps¶
Decision: All infrastructure changes go through git push → Komodo deploy.
Rationale:
- Audit trail — every change is committed with a message and timestamp
- Reproducibility — any service state can be recreated from git history
- Rollback —
git revert+ deploy restores previous state instantly - No SSH drift — prevents manual changes that aren't tracked
- Collaboration — even as a solo operator, the discipline prevents mistakes
Alternative considered: Direct Docker CLI / Portainer UI — rejected because changes aren't versioned and drift accumulates over time.
Why Caddy Reverse Proxy¶
Decision: Caddy runs on macvlan (192.168.100.10) providing *.home domain routing via AdGuard DNS rewrites.
Rationale:
- Clean URLs —
grafana.homeinstead of100.68.251.84:3002 - Automatic HTTPS — Tailscale certificates via Caddy integration
- Centralized routing — all services reachable via consistent
*.homepattern - DNS integration — AdGuard rewrites
*.home→ Caddy, no manual /etc/hosts
Why macvlan: Caddy gets its own LAN IP, avoiding port conflicts with Unraid nginx on 80/443.
Fallback: Services also expose ports directly on Tailscale IP for debugging and access when DNS/Caddy is down.
Why Komodo Over Alternatives¶
Decision: Komodo as the deployment platform.
Rationale:
| Feature | Komodo | Portainer | Dockge |
|---|---|---|---|
| GitOps native | ✅ | ❌ | Partial |
| Multi-server | ✅ | ✅ (Business) | ❌ |
| Resource sync | ✅ | ❌ | ❌ |
| Pending changes UI | ✅ | ❌ | ❌ |
| Auto-update | ✅ | ✅ | ✅ |
| Self-hosted | ✅ | ✅ | ✅ |
- Komodo's
resource_syncmodel maps perfectly to our category-based directory structure - Pending changes UI provides a review step before deploy (safety gate)
- Periphery agents run on each server for local Docker Compose execution
Why Direct Port Access¶
Decision: Each service binds to a unique port on the Tailscale interface IP.
Rationale:
- Unraid nginx occupies 80/443 on the Tailscale IP and cannot be unbound (risk breaking the Unraid GUI)
- Direct port access avoids conflicts with nginx
- Tailscale ACLs can restrict which devices access which ports
- Services use non-standard ports (3005, 3002, 2283, etc.)
Trade-off: Port numbers must be memorized or documented. Mitigated by the service inventory in codebase-summary.md.
Why Separate Resource Syncs Per Category¶
Decision: Each category has its own [[resource_sync]] entry instead of one global sync.
Rationale:
- Granular deploys — adding a service to
applicationsdoesn't require syncingdatabase - Reduced blast radius — a malformed TOML in one category doesn't block others
- Clear ownership — each category is independently managed
- Parallel workflows — changes to multiple categories can be deployed independently
Alternative considered: Single [[resource_sync]] for all stacks — rejected because
one bad TOML would block all deployments.
Why destroy_before_deploy = true¶
Decision: All stacks use destroy_before_deploy = true by default.
Rationale:
- Clean state — removes stale containers, networks, and volumes
- No config drift — ensures deploy starts from a known state
- Simple recovery — just redeploy if something goes wrong
Trade-off: Brief downtime during deploy. Acceptable for a homelab with no SLA requirements.
Why Hardware Transcoding (VAAPI)¶
Decision: Jellyfin uses Intel iGPU via VAAPI for hardware-accelerated transcoding.
Rationale:
- CPU savings — software transcoding maxes CPU cores; VAAPI offloads to iGPU
- Better quality — Intel iHD driver supports H.264/H.265/AV1 decode/encode
- Low latency — direct render nodes via
/dev/driavoid overhead - Always available — Unraid server has Intel iGPU with VAAPI support built-in
Implementation: Mount /dev/dri device in Jellyfin compose, select VAAPI as
transcoding method in Jellyfin settings. No extra container or driver needed.
Why MeTube Audio/Video Routing¶
Decision: MeTube splits downloads by content type to different mount paths.
Rationale:
AUDIO_DOWNLOAD_DIR=/audio→ audio files land in/mnt/user/music/- Video files land in
/mnt/user/downloads/youtube/ - Navidrome scans
/mnt/user/music/automatically — new music appears without manual copy - 120-minute subscription check interval balances freshness vs API rate limits
- State persisted at
/mnt/user/appdata/metube/state/survives container recreates
Alternative considered: Single download directory + manual sorting — rejected because automated routing eliminates a manual step for every music download.
Why Media Services on Same Compose as Storage¶
Decision: MeTube co-located with OpenList+Aria2 in stacks/storage/openlist/.
Rationale:
- Shared infrastructure — MeTube uses Aria2 as download engine (already in OpenList stack)
- Volume proximity — all media services write to
/mnt/user/paths on the same server - Simplified networking — same Docker network, no cross-server file transfers needed
Tag Conventions and Their Purpose¶
| Pattern | Purpose | Used By |
|---|---|---|
server:{name} |
Routes stack to correct periphery | Komodo deploy |
category:{name} |
Groups stacks for filtering | Dashboard, alerts |
saas:{name} |
Marks SaaS-integrated services | Access control |
selfhost:{name} |
Tracks SaaS replacements | Inventory |
app:{name} |
Application-level identifier | Cross-reference |
Tags enable filtering in Komodo UI and can drive alert routing via the alerter config.
Secrets Management Approach¶
Decision: Komodo Variables with [[VAR_NAME]] syntax in TOML environment blocks.
Rationale:
- Never in git — secrets live in Komodo's variable store
- Simple syntax —
[[DB_PASSWORD]]resolved at deploy time by periphery - Centralized — one place to rotate secrets across all stacks
- UI-managed — update via Komodo UI or
km update variable
Classification rule: Real secrets (external auth, billing) → Komodo Variable. Internal keys (service-to-service, homelab-only) → hardcode in compose.yaml.
Rejected approaches:
.envfiles in repo — risk of accidental commit- Vault — overkill for a homelab
- Docker secrets — limited to Swarm mode
Why Tailscale Over WireGuard/ZeroTier¶
Decision: Tailscale as the VPN overlay network.
Rationale:
- Zero config — automatic key distribution, no manual peer management
- Built-in DNS — MagicDNS for service discovery
- ACLs — fine-grained access control per device
- Subnet routers — can route to non-Tailscale networks
- Certificate authority — auto-issued TLS certs via
ts.netdomain
Why AdGuard High Availability¶
Decision: AdGuard replica + sync instead of single primary DNS server.
Rationale:
| Factor | Single Primary | Primary + Replica |
|---|---|---|
| SPOF risk | High — unraid down = no DNS | Low — replica fails over automatically |
| Management overhead | Low — one server to manage | Medium — sync service to monitor |
| Change propagation | Instant | Up to 30min lag (sync interval) |
| Complexity | Simple | Moderate — one-way sync only |
Trade-offs accepted:
- 30min sync lag: DNS changes made on primary take time to reach replica. Acceptable for homelab where filters rarely change.
- One-way sync: Replica is read-only copy. Changes made during primary outage do not sync back. Prevents split-brain conflicts.
- Extra stack: Added 2 services (replica + sync) to mitigate SPOF. Worth it for critical DNS infrastructure.
Why not bi-directional sync: Risk of conflicting changes if both servers modified independently. One-way sync ensures primary is always source of truth.
Failover behavior: DHCP clients automatically fall back: primary → replica → 1.1.1.1 (no ad blocking at fallback).
Related Documentation¶
- System Architecture — How these decisions manifest
- Code Standards — Conventions that follow from these decisions
- Project Overview — Tech stack and scope