Skip to content

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
  • Rollbackgit 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 URLsgrafana.home instead of 100.68.251.84:3002
  • Automatic HTTPS — Tailscale certificates via Caddy integration
  • Centralized routing — all services reachable via consistent *.home pattern
  • 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_sync model 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 applications doesn't require syncing database
  • 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/dri avoid 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:

  • .env files 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.net domain

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).