[Homelab]_
>cat README.md
Reference manual for a 14-stack, 27-container self-hosted Docker environment. Every service explained — what it does, why it's here, how it's configured, and the essential setup notes you need to reproduce the pattern. Identity-gated external access, structurally-isolated network paths, tiered encrypted backups.
This document is a public reference guide for a personal homelab — a single-host Docker environment running fourteen compose stacks across twenty-seven containers. It serves as a personal cloud, media library, password manager, wiki, network DNS sinkhole, and small game-server platform.
A self-hosted environment on commodity hardware. Every public service is reachable through an outbound-only Cloudflare Tunnel with identity-aware Access policies. The home router has no inbound ports open. Internal services use a mix of bridge networks, host networking, and shared network namespaces depending on each service's needs. Secrets are managed deliberately; backups are tiered and decoupled.
Anyone who runs (or wants to run) a self-hosted environment of similar shape. The aim is reference value — concrete patterns you can apply directly. If you have never deployed a Docker container before, the vendor docs linked in each stack chapter will be a better starting point. If you have run containers but want to see how a complete, intentional, security-aware environment fits together, that's what this document describes.
This is a public document. Personal identifiers (domain name, LAN IPs, owner-specific usernames
and host paths) have been replaced with generic placeholders (example.com, 10.0.0.10,
user, /srv/user/...). Credentials are replaced with
Redacted rendered in red so they
stand out. The structural information — what each container does, how the stacks fit together,
the patterns and trade-offs — is intentionally complete.
Five principles guide every architectural decision in this environment. They are the product of operational experience — most were learned through previous failures.
No service is run with bare docker run commands. Every container is declared in
a compose file, managed in Portainer, and reproducible from the compose plus its data volume on
a fresh host. The compose files are the documentation of intent.
Nothing in this environment is reachable from the public internet by virtue of an open port. The home router presents zero attack surface. Every public endpoint is published via a single outbound-only Cloudflare Tunnel, with Cloudflare Access policies in front of every hostname. Identity assertion happens at the edge; downstream services trust the assertion.
The download client lives inside a VPN container's network namespace and inherits its network stack. If the VPN drops or the gluetun container crashes, Downloader loses internet access entirely. There is no fallback path. Kill-switch behavior is structural — a property of the topology — not a runtime check that could fail.
Vaultwarden produces nightly local tarballs to a NAS cache via a read-only sidecar. Duplicati independently ingests those tarballs and ships encrypted, deduplicated snapshots to long-term storage. The two tiers are independent — a Duplicati outage doesn't break the local backup, and a vault corruption doesn't propagate because the source mount is read-only.
Fast working data lives on the local SSD. Bulk media and backups live on NAS. Mount paths are explicit in every compose file. No implicit symlinks, no magic locations. Disaster recovery is recoverable by inspection alone.
What you need to deploy a similar environment.
get.docker.com) or your distro's repository.docker compose subcommand.07). Optional if you prefer pure CLI.docker-compose.yml.Every compose stack on the host, grouped by category.
| Stack | Category | Services | Ports | Public hostname |
|---|---|---|---|---|
| mediastacks | Media | plex, sonarr, radarr, prowlarr, bazarr, notifiarr | 8989, 7878, 9696, 6767, 5454 | plex / sonarr / radarr / bazarr / prowlarr / notifiarr |
| downloader-vpn | Media | gluetun, downloader | 8080, 8090, 8090, 8888 | (LAN only · :8080) |
| jellyseerr | Media | jellyseerr | 5055 | jellyseerr.example.com |
| tautulli | Media | tautulli | 8181 | tautulli.example.com |
| nextcloud-mariadb | Productivity | db, app | 8083 | cloud.example.com |
| docmost | Productivity | docmost, db, redis | 3000 | docmost.example.com |
| vaultwarden | Productivity | vaultwarden, vaultwarden-backup | 8087 | vaultwarden.example.com |
| samba-nas | Productivity | samba | — | (LAN only · SMB) |
| duplicati | Productivity | duplicati | 8200 | (LAN only · :8200) |
| cloudflare-tunnel | Network | cloudflared | — | (meta — provides the tunnel) |
| adguard | Network | adguard | — | adguard.example.com |
| flame-dashboard | Tools | flame | 5005 | (LAN only · :5005) |
| uptime-kuma | Tools | uptime-kuma | 3001 | status.example.com |
| code-server | Tools | code-server | 8443 | code.example.com |
The plumbing that everything else assumes.
On a fresh Ubuntu / Debian host, the official convenience script gets you running quickly:
curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # Log out and back in for the group change to take effect
For production environments, prefer your distro's official package or Docker's official APT/DNF repositories. See docs.docker.com/engine/install.
Portainer provides a web UI for managing stacks, plus a REST API used throughout this lab. Install it as its own container with the data volume on a known path:
docker volume create portainer_data docker run -d \ --name portainer \ --restart always \ --network host \ -v portainer_data:/data \ -v /var/run/docker.sock:/var/run/docker.sock \ portainer/portainer-ce:latest # UI: http://<host>:9000 (set admin password on first visit)
See docs.portainer.io/start/install-ce.
This lab uses a consistent path convention so every service's bind mounts are predictable:
/srv/user/docker/<service>/config — per-service configuration, lives on local SSD./mnt/storage/NAS/... — NAS share for bulk data (Nextcloud, backups)./mnt/storage/BulkStore/... — deep media archive on a second NAS mount point.Adopting the same convention across stacks means recovery procedures are uniform: re-mount the storage, reinstate the compose, start the stack — done.
Most LinuxServer.io images accept PUID / PGID env vars to specify
the UID/GID the container process runs as. Match these to the host user that owns the bind-mount
directories. Get the values with id:
id # uid=1000(user) gid=1000(user) groups=1000(user),... # Use PUID=1000, PGID=1000 in your compose files
Misaligned PUID/PGID is the #1 cause of "permission denied" errors when containers try to write to bind mounts.
The foundational pattern that makes external access secure: outbound-only tunnel to Cloudflare's edge with identity-aware authentication in front of every public hostname. No inbound ports on the home router.
Traditional self-hosting opens a port on the home router and forwards it to a service. That port is reachable from the entire internet. Even with strong authentication, the service's attack surface (banner, version, login form, any RCE vulnerability) is exposed. With a tunnel, there is no port to scan, no banner to fingerprint, no edge service to harden in the homelab. The whole class of pre-authentication attacks goes away.
one.dash.cloudflare.com) create a Tunnel.cloudflared container.service.example.com → http://localhost:<port>.See the cloudflare-tunnel stack chapter for the production compose with annotations.
At minimum:
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared-connector
restart: unless-stopped
command: tunnel run --token Redacted
networks:
- cloudflare-net
dns:
- 1.1.1.1
- 1.0.0.1
networks:
cloudflare-net:
external: true
1.1.1.1 so the tunnel can register on cold boot.developers.cloudflare.com/cloudflare-one/connections/connect-networks — tunnelsdevelopers.cloudflare.com/cloudflare-one/policies/access — Access policiesComplete media automation suite. Plex serves the library to LAN and remote clients; the *arr stack (Sonarr / Radarr / Bazarr / Prowlarr) handles indexer search, downloads, and subtitle acquisition. Notifiarr is the notification fan-out for events across the suite.
| Service | Image | Container | Network |
|---|---|---|---|
| plex | plexinc/pms-docker | plex | host |
| sonarr | lscr.io/linuxserver/sonarr:latest | sonarr | bridge |
| radarr | lscr.io/linuxserver/radarr:latest | radarr | bridge |
| prowlarr | lscr.io/linuxserver/prowlarr:latest | prowlarr | bridge |
| bazarr | lscr.io/linuxserver/bazarr:latest | bazarr | bridge |
| notifiarr | golift/notifiarr:latest | notifiarr | bridge |
| Images | plexinc/pms-docker, lscr.io/linuxserver/{sonarr,radarr,bazarr,prowlarr}:latest, golift/notifiarr:latest |
| Required env | PUID / PGID (host user), TZ, PLEX_CLAIM (get from plex.tv/claim within 4 minutes of first run) |
| Common pitfall | Volumes must be mounted at the SAME path inside Sonarr/Radarr AND the download client so hardlinks work and files don't get copied across imports. |
| Vendor docs | plex.tv/about/plex-app/plex-media-server · docs.linuxserver.io |
/srv/user/docker/<service>/config — survives container recreate. Image updates apply as a batch via Portainer; LinuxServer.io images tend to be stable. Recovery from container loss is reinstating compose + the config volume.version: "3.9" services: plex: image: plexinc/pms-docker container_name: plex # Plex needs host networking for DLNA / GDM discovery — direct-play to LAN clients with no NAT overhead. network_mode: host environment: - PUID=1000 - PGID=1000 - VERSION=docker # Claim token placeholder — replace with one from plex.tv/claim before first boot, or remove if already claimed via UI. - PLEX_CLAIM=Redacted volumes: - /srv/user/docker/plex/config:/config - /srv/user/Desktop/MoviesAndShows/Movies:/movies - /srv/user/Desktop/MoviesAndShows/Shows:/shows - /mnt/storage/BulkStore/Movies:/bulkstoremovies - /mnt/storage/BulkStore/Shows:/bulkstoreshows - /srv/user/Desktop/MoviesAndShows/Downloads:/downloads - /mnt/storage/BulkStore/Downloads:/bulkstoredownload restart: unless-stopped tmpfs: - "/transcode:size=8g" # https://plex.example.com sonarr: image: lscr.io/linuxserver/sonarr:latest container_name: sonarr ports: - "8989:8989" environment: - PUID=1000 - PGID=1000 - TZ=America/New_York volumes: - /srv/user/docker/sonarr/config:/config - /srv/user/Desktop/MoviesAndShows/Shows:/tv - /mnt/storage/BulkStore/Shows:/bulkstoreshows - /srv/user/Desktop/MoviesAndShows/Downloads:/downloads - /mnt/storage/BulkStore/Downloads:/bulkstoredownload restart: unless-stopped # https://sonarr.example.com radarr: image: lscr.io/linuxserver/radarr:latest container_name: radarr ports: - "7878:7878" environment: - PUID=1000 - PGID=1000 - TZ=America/New_York volumes: - /srv/user/docker/radarr/config:/config - /srv/user/Desktop/MoviesAndShows/Movies:/movies - /mnt/storage/BulkStore/Movies:/bulkstoremovies - /srv/user/Desktop/MoviesAndShows/Downloads:/downloads - /mnt/storage/BulkStore/Downloads:/bulkstoredownload restart: unless-stopped # https://radarr.example.com prowlarr: image: lscr.io/linuxserver/prowlarr:latest container_name: prowlarr ports: - "9696:9696" environment: - PUID=1000 - PGID=1000 - TZ=America/New_York volumes: - /srv/user/docker/prowlarr/config:/config restart: unless-stopped # https://prowlarr.example.com bazarr: image: lscr.io/linuxserver/bazarr:latest container_name: bazarr ports: - "6767:6767" environment: - PUID=1000 - PGID=1000 - TZ=America/New_York volumes: - /srv/user/docker/bazarr/config:/config - /srv/user/Desktop/MoviesAndShows/Movies:/movies - /srv/user/Desktop/MoviesAndShows/Shows:/tv - /mnt/storage/BulkStore/Movies:/bulkstoremovies - /mnt/storage/BulkStore/Shows:/bulkstoreshows restart: unless-stopped # https://bazarr.example.com notifiarr: image: golift/notifiarr:latest container_name: notifiarr ports: - "5454:5454" environment: - PUID=1000 - PGID=1000 - TZ=America/New_York - NOTIFIARR_API_KEY=Redacted volumes: - /srv/user/docker/notifiarr/config:/config restart: unless-stopped # https://notifiarr.example.com
Downloader routed exclusively through a ProtonVPN tunnel via gluetun. Downloader has no direct network interface — it inherits gluetun's namespace. If the VPN drops or gluetun crashes, Downloader loses internet entirely. The canonical Docker VPN kill-switch pattern.
| Service | Image | Container | Network |
|---|---|---|---|
| gluetun | qmcgaw/gluetun | gluetun | bridge |
| downloader | lscr.io/linuxserver/sabnzbd:latest | downloader | service:gluetun |
| Images | qmcgaw/gluetun, lscr.io/linuxserver/sabnzbd:latest |
| Required env | VPN_SERVICE_PROVIDER, VPN_TYPE, OPENVPN_USER / OPENVPN_PASSWORD (or wireguard equivalents). For ProtonVPN add +pmp suffix to the username for port-forwarding. |
| Common pitfall | Forgetting cap_add: NET_ADMIN on gluetun — tunnel won't come up. And network_mode: service:gluetun on downloader must reference the EXACT gluetun service name. |
| Vendor docs | github.com/qdm12/gluetun-wiki · docs.linuxserver.io/images/docker-downloader |
network_mode: service:gluetun is the simplest way to guarantee a container can ONLY reach the internet through the VPN. No fallback path. Kill-switch behavior is structural — a property of the topology — not a runtime check that could fail.:latest with regular update cadence. Health observable through gluetun's logs (tunnel up/down, port forwarding events, public IP geolocation). Downloader WebUI at :8080 reachable via the gluetun namespace.version: "3.8" services: gluetun: image: qmcgaw/gluetun container_name: gluetun # NET_ADMIN is required so gluetun can bring up the VPN tunnel interface. cap_add: - NET_ADMIN environment: # gluetun handles provider-specific quirks — server lists, credentials format, kill-switch rules. - VPN_SERVICE_PROVIDER=protonvpn - VPN_TYPE=openvpn # ProtonVPN port forwarding is required for inbound peer connections through the VPN's NAT. - VPN_PORT_FORWARDING=on - OPENVPN_USER=Redacted #Also don't forget to add +pmp - OPENVPN_PASSWORD=Redacted - SERVER_COUNTRIES=United States # Or Switzerland, USA, etc. # Open this port on gluetun's internal firewall. (Known drift: ProtonVPN assigns a dynamic port — see Cross-cutting · VPN isolation.) - FIREWALL_VPN_INPUT_PORTS=8090 ports: - "8080:8080" # Downloader Web UI - "8090:8090" # download peer port (TCP) - "8090:8090/udp" # download peer port (UDP) - "8888:8888/tcp" # Gluetun Web UI (uncomment if you want it) restart: unless-stopped downloader: image: lscr.io/linuxserver/sabnzbd:latest container_name: downloader environment: - PUID=1000 - PGID=1000 - TZ=America/New_York - WEBUI_PORT=8080 volumes: - /srv/user/docker/downloader/config:/config - /srv/user/Desktop/MoviesAndShows/Downloads:/downloads - /mnt/storage/BulkStore/Downloads:/bulkstoredownload - /mnt/storage/BulkStore/ManualDownload:/manualdownload # Kill-switch pattern — downloader shares gluetun's network namespace. If gluetun stops, downloader loses ALL internet. network_mode: "service:gluetun" # Ensures gluetun starts first so its namespace exists when downloader attaches. depends_on: - gluetun restart: unless-stopped
Self-service media request portal. Users browse what is available on Plex, request what is not, and Jellyseerr forwards approved requests to Sonarr/Radarr for automatic acquisition.
| Service | Image | Container | Network |
|---|---|---|---|
| jellyseerr | fallenbagel/jellyseerr:latest | jellyseerr | bridge |
| Image | fallenbagel/jellyseerr:latest |
| Required env | PUID / PGID, TZ. Plex / Sonarr / Radarr connections configured in the web UI on first run. |
| Common pitfall | Use absolute volume paths (not ./jellyseerr-config) — relative paths resolve to the compose dir which can move. |
| Vendor docs | docs.jellyseerr.dev |
/srv/user/docker. Updates every few weeks following upstream.version: '3.8' services: jellyseerr: image: fallenbagel/jellyseerr:latest container_name: jellyseerr environment: - PUID=1000 - PGID=1000 - TZ=America/New_York volumes: # Per-service config convention: /srv/user/docker/<service>/config. - /srv/user/docker/jellyseerr/config:/app/config ports: - "5055:5055" restart: unless-stopped
Plex observability. Tracks watch history, currently-playing sessions, transcoding load, library growth, per-user activity. Pushes notifications on configured events.
| Service | Image | Container | Network |
|---|---|---|---|
| tautulli | tautulli/tautulli | tautulli | bridge |
| Image | tautulli/tautulli:latest |
| Required env | PUID / PGID, TZ. Plex API key configured via the UI after first launch. |
| Common pitfall | Tautulli needs network access to Plex; if Plex is on host network and Tautulli on bridge, use the host IP (not localhost) when configuring. |
| Vendor docs | tautulli.com |
:8181 gated by Cloudflare Access externally; LAN access by login.version: '3.3' services: tautulli: image: tautulli/tautulli container_name: tautulli restart: unless-stopped environment: - PUID=1000 - PGID=1000 - TZ=America/New_York volumes: # Holds Plex history DB — primary long-term value is the historical record. - /srv/user/docker/tautulli:/config ports: - 8181:8181
Self-hosted file sync, calendar, contacts, and office collaboration. Replaces Google Drive / iCloud for personal files. Sync clients on every platform push files into the homelab.
| Service | Image | Container | Network |
|---|---|---|---|
| db | mariadb:11 | nextcloud_db | bridge |
| app | nextcloud:33-apache | nextcloud_app | bridge |
| Images | nextcloud:33-apache (pin to major version), mariadb:11 |
| Required env | MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD for the DB; MYSQL_HOST: db on the app pointing at the DB service name. |
| Common pitfall | Nextcloud refuses to skip major versions on upgrade — go one major at a time (29→30→31…). Always DB-dump before each step. |
| Vendor docs | docs.nextcloud.com |
/mnt/storage/NAS/NextCloud/data — containers are disposable, data survives. DB upgrades require sequential major-version migrations (cannot skip); standard procedure is mysqldump + tarball before each step, then redeploy with the new image tag and let the official entrypoint run occ upgrade automatically.version: "3.8" services: db: image: mariadb:11 container_name: nextcloud_db restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: Redacted MYSQL_DATABASE: nextcloud MYSQL_USER: nextcloud MYSQL_PASSWORD: Redacted volumes: - /mnt/storage/NAS/NextCloud/db:/var/lib/mysql # Dedicated bridge network — only nextcloud_app can reach the database. networks: - nextcloud-net app: image: nextcloud:33-apache container_name: nextcloud_app restart: unless-stopped depends_on: - db ports: - 8083:80 environment: MYSQL_DATABASE: nextcloud MYSQL_USER: nextcloud MYSQL_PASSWORD: Redacted # Resolves via Docker's internal DNS to the 'db' service on the bridge network. MYSQL_HOST: db volumes: - /mnt/storage/NAS/NextCloud/app:/var/www/html - /mnt/storage/NAS/NextCloud/data:/var/www/html/data networks: - nextcloud-net networks: nextcloud-net: driver: bridge
Self-hosted Notion-style wiki and collaborative document editor. Used for personal notes, project documentation, and longer-form content.
| Service | Image | Container | Network |
|---|---|---|---|
| docmost | docmost/docmost:latest | docmost | bridge |
| db | postgres:16-alpine | db | bridge |
| redis | redis:7.2-alpine | redis | bridge |
| Images | docmost/docmost:latest, postgres:16-alpine, redis:7.2-alpine |
| Required env | APP_SECRET (generate a long random string), DATABASE_URL matching the DB env, REDIS_URL matching the redis service name. |
| Common pitfall | Restart policies missing by default — add restart: unless-stopped on all three services or Docmost stays down across host reboots. |
| Vendor docs | docmost.com/docs |
.env file). No public access yet; intended for LAN-only or behind future SSO. Postgres exposes no port to the host.docmost / db_data / redis_data). Updates are docker compose pull && up -d; Docmost ships breaking changes occasionally so changelogs are worth checking.version: "3" services: docmost: image: docmost/docmost:latest depends_on: - db - redis environment: APP_URL: "http://localhost:3000" # Used to sign sessions / JWTs. Rotation invalidates all active sessions. APP_SECRET: Redacted DATABASE_URL: Redacted # Redis is queue + cache; opaque to the host (no port exposed). REDIS_URL: "redis://redis:6379" ports: - "3000:3000" restart: unless-stopped volumes: - docmost:/app/data/storage db: image: postgres:16-alpine environment: POSTGRES_DB: docmost POSTGRES_USER: docmost POSTGRES_PASSWORD: Redacted restart: unless-stopped volumes: - db_data:/var/lib/postgresql/data redis: image: redis:7.2-alpine restart: unless-stopped volumes: - redis_data:/data volumes: docmost: db_data: redis_data:
Self-hosted Bitwarden-compatible password manager. Primary credential store for all personal accounts. A sidecar container produces daily encrypted backups to the NAS for off-host shipping by Duplicati.
| Service | Image | Container | Network |
|---|---|---|---|
| vaultwarden | vaultwarden/server:1.36.0-alpine | vaultwarden | bridge |
| vaultwarden-backup | bruceforce/vaultwarden-backup:latest | vaultwarden_backup_tool | bridge |
| Image | vaultwarden/server:1.36.0-alpine (pin — do not run :latest for a password manager) |
| Required env | DOMAIN=https://vaultwarden.example.com (must match the exact public URL or WebAuthn breaks silently), SIGNUPS_ALLOWED=false, INVITATIONS_ALLOWED=false |
| Common pitfall | Forgetting DOMAIN → WebAuthn / passkey 2FA appears to work but cryptographic origin check fails. Leaving SIGNUPS_ALLOWED default (TRUE) → anyone reaching the URL can register. |
| Vendor docs | github.com/dani-garcia/vaultwarden/wiki |
SIGNUPS_ALLOWED=false, INVITATIONS_ALLOWED=false, SHOW_PASSWORD_HINT=false, DOMAIN set to the public URL (required for WebAuthn / passkey 2FA origin validation), image pinned to vaultwarden/server:1.36.0-alpine (smaller attack surface). External access gated by Cloudflare Access on top of Vaultwarden's own 2FA. Admin panel disabled (no ADMIN_TOKEN set).vaultwarden-backup sidecar mounts it READ-ONLY and runs a nightly cron (30 2 * * *) producing timestamped tar.xz files to the NAS cache. Duplicati then picks those tarballs up for off-host backup. Two independent backup tiers.version: '3.8' services: vaultwarden: image: vaultwarden/server:1.36.0-alpine container_name: vaultwarden restart: unless-stopped ports: - "8087:80" volumes: - vaultwarden_data:/data environment: # Must match the public URL exactly — required for WebAuthn / passkey 2FA origin validation. - DOMAIN=https://vaultwarden.example.com # Upstream default is TRUE — explicitly disabled to prevent unauthorized account creation. - SIGNUPS_ALLOWED=false # Closes the secondary path: even existing users cannot invite new ones. - INVITATIONS_ALLOWED=false - SHOW_PASSWORD_HINT=false - WEBSOCKET_ENABLED=true - TZ=America/New_York vaultwarden-backup: image: bruceforce/vaultwarden-backup:latest container_name: vaultwarden_backup_tool restart: unless-stopped volumes: - vaultwarden_data:/data:ro - /mnt/storage/NAS/Vaultwarden_Backup_Cache:/backup environment: - TIMESTAMP=true - PUID=1000 - PGID=1000 - CRON_TIME=30 2 * * * - TZ=America/New_York - BACKUP_DIR=/backup - DOCKER_CONTAINER_NAME=vaultwarden - BACKUP_ON_STARTUP=true - DELETE_AFTER=30 volumes: vaultwarden_data:
Exposes the NAS over SMB so Windows / macOS / Linux clients on the LAN can mount it as a network drive.
| Service | Image | Container | Network |
|---|---|---|---|
| samba | dperson/samba | samba | host |
| Image | crazymax/samba (recommended; dperson/samba is abandoned) |
| Required env | USERID / GROUPID matching the host owner of the bind path; an smb.conf mounted in or built via env vars defining the share. |
| Common pitfall | Permissions: the host UID owning the bind dir must match what the container is told to run as, or SMB clients write files that don't match their owner. |
| Vendor docs | github.com/crazy-max/docker-samba |
dperson/samba) is several years stale; replacing with crazymax/samba + an smb.conf with explicit guest ok = no is recommended. LAN-only — never exposed externally.version: "3.8" services: samba: image: dperson/samba container_name: samba restart: unless-stopped # Host networking so SMB broadcast and NetBIOS discovery work without bridge gymnastics. network_mode: host environment: - USERID=1000 - GROUPID=1000 volumes: - /mnt/storage/NAS:/media/NAS # Single share 'NAS' backed by the bulk mount, single user. command: > -s "NAS;/media/NAS;yes;no;yes;all" -u "user;Redacted"
Encrypted, deduplicated, scheduled backups. Picks up the Vaultwarden tarball cache and ships it to long-term backup storage. Configured to back up other critical config paths on the host.
| Service | Image | Container | Network |
|---|---|---|---|
| duplicati | lscr.io/linuxserver/duplicati:latest | duplicati | bridge |
| Image | lscr.io/linuxserver/duplicati:latest |
| Required env | PUID / PGID, TZ, SETTINGS_ENCRYPTION_KEY (strong random string — losing it means losing access to all backups Duplicati creates). |
| Common pitfall | Storing the encryption key only inside Duplicati itself — keep a copy in Vaultwarden, otherwise loss of the container = loss of all backups. |
| Vendor docs | docs.linuxserver.io/images/docker-duplicati |
.env). Source mounts READ-ONLY. Destination is a separate NAS path from any source — backup-set corruption cannot take out source data.:8200 for backup config and restore. The Duplicati internal database (in /opt/duplicati/config) holds retention rules and backup history — back this up separately so retention metadata is not lost if the container dies.version: '3.8' services: duplicati: image: lscr.io/linuxserver/duplicati:latest container_name: duplicati restart: unless-stopped volumes: # Holds Duplicati's internal DB and retention rules — back this up separately or you lose backup history. - /opt/duplicati/config:/config # Destination is a separate NAS path from any source — backup-set corruption cannot take out source data. - /mnt/storage/NAS/Duplicati_Backups:/destination # Source mounted READ-ONLY — the backup process cannot accidentally damage source data. - /mnt/storage/NAS/Vaultwarden_Backup_Cache:/vaultwarden:ro environment: - PUID=1000 - PGID=1000 - TZ=America/New_York - SETTINGS_ENCRYPTION_KEY=Redacted ports: - 8200:8200
Outbound-only tunnel to Cloudflare's edge. Exposes lab services on the public internet without opening any inbound ports on the home router.
| Service | Image | Container | Network |
|---|---|---|---|
| cloudflared | cloudflare/cloudflared:latest | cloudflared-connector | bridge |
| Image | cloudflare/cloudflared:latest |
| Required env | --token <tunnel-token> in the command field — obtain from Cloudflare Zero Trust dashboard after creating a tunnel. |
| Common pitfall | Routing the tunnel container through AdGuard for DNS — boot-order deadlock if AdGuard is not yet up. Always pin DNS to 1.1.1.1 for this container. |
| Vendor docs | developers.cloudflare.com/cloudflare-one/connections/connect-networks |
1.1.1.1 / 1.0.0.1) bypasses the AdGuard container during tunnel registration — prevents a boot-order deadlock where cloudflared cannot resolve Cloudflare's edge because AdGuard is not yet running. Updates are security-sensitive — always applied promptly.version: '3.7' services: cloudflared: image: cloudflare/cloudflared:latest container_name: cloudflared-connector restart: unless-stopped # Outbound-only persistent connection to Cloudflare edge. No inbound port required on the home router. command: tunnel run --token Redacted networks: - cloudflare-net # Custom DNS (1.1.1.1) bypasses the AdGuard container during tunnel registration to avoid a boot-order deadlock. dns: - 1.1.1.1 - 1.0.0.1 networks: cloudflare-net: # This tells Docker Compose that the network was created externally in Portainer # Network is created in Portainer separately so other stacks can attach for inbound routing. external: true
Network-wide DNS sinkhole. Blocks ads, trackers, and malware domains for every device on the LAN that uses it as a resolver.
| Service | Image | Container | Network |
|---|---|---|---|
| adguard | adguard/adguardhome:latest | adguard | host |
| Image | adguard/adguardhome:latest |
| Required env | TZ. First-run wizard at http://<host>:3000 sets admin credentials and binds DNS to :53. |
| Common pitfall | Conflict with systemd-resolved binding port 53 on the host. Disable / reconfigure resolved before starting the container. |
| Vendor docs | github.com/AdguardTeam/AdGuardHome/wiki |
version: "3.8" services: adguard: image: adguard/adguardhome:latest container_name: adguard restart: unless-stopped # Host networking required to bind port 53 directly and serve as the LAN's primary DNS. network_mode: host volumes: # Query logs and runtime state — survives container recreate. - /opt/adguard/work:/opt/adguardhome/work - /opt/adguard/conf:/opt/adguardhome/conf environment: - TZ=America/New_York
Personal homepage / app launcher. Customizable tile grid linking to every service in the lab.
| Service | Image | Container | Network |
|---|---|---|---|
| flame | pawelmalak/flame:latest | flame | bridge |
| Image | pawelmalak/flame:latest (or ghcr.io/gethomepage/homepage:latest as a modern replacement) |
| Required env | PASSWORD_FILE pointing at a mounted file containing the admin password. |
| Common pitfall | Mounting the Docker socket read-write — Flame only needs read access for container discovery. Always use :ro. |
| Vendor docs | github.com/pawelmalak/flame (project) · gethomepage.dev (recommended alternative) |
gethomepage.dev (Homepage) is a more actively-maintained alternative.services: flame: image: pawelmalak/flame:latest container_name: flame volumes: - /opt/flame/data:/app/data # Read-only socket mount for container auto-discovery. Consider docker-socket-proxy for additional safety. - /var/run/docker.sock:/var/run/docker.sock:ro - /opt/flame/secrets/password:/run/secrets/password:ro ports: - "5005:5005" environment: - PASSWORD_FILE=Redacted restart: unless-stopped
Self-hosted status page and uptime monitor. Pings every service in the lab; sends notifications on failure.
| Service | Image | Container | Network |
|---|---|---|---|
| uptime-kuma | louislam/uptime-kuma:latest | uptime-kuma | bridge |
| Image | louislam/uptime-kuma:latest |
| Required env | TZ. First-run wizard at http://<host>:3001 sets admin credentials. |
| Common pitfall | Running on the same host as the services it monitors — if the host dies, the monitor dies with it. A small Raspberry Pi instance elsewhere is a good backup. |
| Vendor docs | github.com/louislam/uptime-kuma/wiki |
/opt/uptime-kuma/data. Updates are stable; image bumps roughly quarterly. The monitor list is the most valuable artifact — export periodically.services: uptime-kuma: image: louislam/uptime-kuma:latest container_name: uptime-kuma restart: always volumes: - /opt/uptime-kuma/data:/app/data # Read-only socket mount enables container-state monitoring beyond HTTP checks. - /var/run/docker.sock:/var/run/docker.sock:ro ports: - "3001:3001" environment: - TZ=America/New_York
Browser-based VS Code, accessible from any device on the LAN. Useful for editing configs and writing scripts from a tablet or borrowed PC without an SSH client.
| Service | Image | Container | Network |
|---|---|---|---|
| code-server | lscr.io/linuxserver/code-server:latest | code-server | bridge |
| Image | lscr.io/linuxserver/code-server:latest |
| Required env | PUID / PGID, TZ, PASSWORD (or HASHED_PASSWORD) for web access. |
| Common pitfall | Placeholder /path/to/... in the example compose — replace with a real persistent path before first launch or extensions and settings vanish on container recreate. |
| Vendor docs | docs.linuxserver.io/images/docker-code-server |
:8443. Config dir mirrors a real user's home so settings survive recreate.services: code-server: image: lscr.io/linuxserver/code-server:latest container_name: code-server environment: - PUID=1000 - PGID=1000 - TZ=Etc/UTC volumes: # Per-service config convention: /srv/user/docker/<service>/config. - /srv/user/docker/code-server/config:/config ports: - 8443:8443 restart: unless-stopped
A single cloudflared container holds an outbound-only persistent connection to
Cloudflare's edge. There are no inbound ports on the home router. Every public hostname
terminates at Cloudflare's edge, where an Access policy evaluates before any request is forwarded
into the homelab. Most policies restrict to a single Cloudflare-authenticated email; a small
number have service-token policies for programmatic access. The attack surface presented to the
public internet is exactly zero open ports.
One service in this environment uses Docker's per-container network namespace as a hard
kill-switch: a VPN container (gluetun) brings up the tunnel and owns the namespace, and a
client container joins it with network_mode: service:gluetun. If the VPN drops
or the gluetun container crashes, the client loses all network access — there is no leak path.
This is enforced by Docker's network model, not by a runtime check.
Two-tier, asynchronous, with decoupled failure domains.
Tier 1: a sidecar container (vaultwarden-backup) mounts the
Vaultwarden data volume read-only and runs a nightly cron (30 2 * * *) producing
timestamped tarballs to a NAS cache.
Tier 2: Duplicati ingests that cache (read-only) and produces encrypted, deduplicated, versioned snapshots to a separate destination path.
Either tier can fail without taking out the other. Source mounts being read-only is a hard guarantee that the backup process cannot damage source data — a property of the mount, not a permission check.
three patterns are used deliberately: host networking for services needing direct LAN access or specific port binding (Plex GDM, AdGuard DNS, Samba SMB); bridge networks as the default for everything else; and namespace sharing for select VPN-isolated workloads.
Image updates follow a categorized batch process. Security-sensitive images first (cloudflared, AdGuard). Application images second (Plex, *arr, Jellyseerr, Tautulli, Notifiarr). Infrastructure images third (PostgreSQL, Redis, MariaDB, Docmost, Duplicati, code-server, Uptime Kuma). For each: pull the new image, re-apply the stack via Portainer, verify container health, check service endpoint. Rollback is a single API call with the saved compose backup.
Uptime Kuma watches HTTP endpoints and container state via a read-only Docker socket mount. Tautulli watches Plex specifically. AdGuard provides DNS-layer visibility for the whole LAN. There is intentionally no metrics stack (Prometheus / Grafana) — the marginal value did not justify the operational overhead for a single-user lab. Log aggregation is a known gap; Dozzle or a small Loki + Grafana is the planned addition.
Event notifications (new media, download grabbed, health issues, playback activity) reach Discord through Notifiarr, which runs in two halves:
Local client — the notifiarr container on the host watches the
*arr apps and Plex. It holds no Discord credentials. Its only outbound link is to
notifiarr.com, authenticated by a single API key.
Cloud + bot — notifiarr.com receives the client's events and
posts formatted messages to Discord via Notifiarr's own bot, into channels configured on the
website. The Discord linkage (which server, which channel, which events) lives entirely on the
notifiarr.com side, not in the homelab.
Flow: homelab app → notifiarr client → notifiarr.com (API key) → Notifiarr Discord bot → your channel.
A consequence of this design: only one client may "own" the *arr apps on a given Notifiarr
account at a time — stale client registrations (e.g. left over after a container rebuild) must be
removed on notifiarr.com, or they conflict. The Discord credentials never touch the homelab, which
keeps the local attack surface clean: a compromise of the homelab does not expose a Discord bot token.
Some services can also notify Discord directly via a plain webhook (Uptime Kuma, and the *arr apps natively) — a simpler path that bypasses Notifiarr's cloud entirely when rich formatting isn't needed.
Honest assessment: most service-level secrets (database passwords, API keys, VPN credentials,
tunnel tokens, encryption keys) are inline in compose files. The recommended evolution is
.env files referenced from compose for first-step improvement, then Docker secrets
or an external secret store for higher-value credentials. Personal credentials are held in
Vaultwarden — that's the right place — but lab-internal service secrets are not yet in a managed
store.
Every container image referenced in this environment, grouped by stack.
All bind mounts and named volumes per service.
Network modes and exposed ports per service.
Terms used in this document, briefly defined.
Technologies and patterns documented in this environment.
> end of document