[Homelab]_
>cat README.md
Reference manual for a 31-stack, 51-container self-hosted Docker environment. Every service explained: what it does, why it's here, how it's configured, and the essential setup notes needed to reproduce the pattern. Identity-gated external access, structurally-isolated network paths, tiered encrypted backups.
A reference guide for a single-host Docker environment running 31 compose stacks across 51 containers. It serves as a personal cloud, media library, password manager, wiki, network DNS resolver, and small game-server platform, with every public service reachable only through an identity-gated Cloudflare tunnel.
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 | sonarr.example.com · radarr.example.com · prowlarr.example.com · bazarr.example.com · plex.example.com |
| jellyfin | Media | jellyfin | — | jellyfin.example.com |
| sabnzbd | Media | sabnzbd | 8091 | sab.example.com |
| downloader-vpn | Media | gluetun, downloader | 8080, 8090, 8090, 8888 | qbit.example.com |
| unpackerr | Media | unpackerr | — | (no UI. Background worker) |
| jellyseerr | Media | jellyseerr | 5055 | jelly.example.com |
| tautulli | Media | tautulli | 8181 | plexdash.example.com |
| jellystat | Media | jellystat-db, jellystat | 8088 | jellydash.example.com |
| jsr-titles | Media | jsr-titles | 9999 | (LAN only · :9999) |
| nextcloud-mariadb | Productivity | db, app, redis | 8083 | cloud.example.com |
| docmost | Productivity | docmost, db, redis | 3000 | docmost.example.com |
| vaultwarden | Productivity | vaultwarden, vaultwarden-backup | 8087 | vaultwarden.example.com |
| links | Productivity | db, api, web, backup | ${WEB_PORT | links.example.com · links2.example.com (staging) |
| stirling-pdf | Productivity | stirling-pdf | 8100 | pdf.example.com |
| samba-nas | Productivity | samba | — | (LAN only · SMB) |
| duplicati | Productivity | duplicati | 8200 | backup.example.com |
| cloudflare-tunnel | Network | cloudflared | — | (meta. Provides the tunnel) |
| adguard | Network | adguard | — | adguard.example.com |
| socket-proxy | Network | socket-proxy | 127.0.0.1 | (internal. No UI) |
| homepage | Tools | homepage | 7676 | server.example.com |
| flame-dashboard | Tools | flame | 5005 | example.com (apex) |
| uptime-kuma | Tools | uptime-kuma | 3001 | status.example.com |
| beszel | Tools | beszel, beszel-agent | 8090 | beszel.example.com |
| wud | Tools | wud | 8094 | (LAN only · :8094) |
| ntfy | Tools | ntfy | 8093 | ntfy.example.com |
| dozzle | Tools | dozzle | 8101 | logs.example.com |
| config-backup | Tools | config-backup | — | (no UI. Scheduled job) |
| mount-monitor | Tools | mount-monitor | — | (no UI. Background worker) |
| code-server | Tools | code-server | 8443 | code.example.com |
| trek | Tools | trek | 8089 | trek.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 runtime: nvidia # 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 - NVIDIA_VISIBLE_DEVICES=all - NVIDIA_DRIVER_CAPABILITIES=compute,video,utility 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,uid=1000,gid=1000,mode=1777" # 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
Open-source media server running alongside Plex against the same library. Acts as a redundant front door. If Plex breaks or its account system goes wrong, Jellyfin still serves the same files. Also avoids Plex Pass dependency for hardware transcoding (Jellyfin includes NVENC free).
| Service | Image | Container | Network |
|---|---|---|---|
| jellyfin | jellyfin/jellyfin:latest | jellyfin | host |
| Image | jellyfin/jellyfin:latest |
| Required env | TZ; for GPU: NVIDIA_VISIBLE_DEVICES=all, NVIDIA_DRIVER_CAPABILITIES=compute,video,utility, and runtime: nvidia. Set JELLYFIN_PublishedServerUrl to your public hostname so clients discover the right URL. |
| Common pitfall | Don't enable hardware acceleration in Dashboard → Playback until you've confirmed nvidia-smi works inside the container. Enabling NVENC without working modules causes every transcode to fail silently. |
| Vendor docs | jellyfin.org/docs |
version: "3.9" services: jellyfin: image: jellyfin/jellyfin:latest container_name: jellyfin network_mode: host runtime: nvidia environment: - TZ=America/New_York - NVIDIA_VISIBLE_DEVICES=all - NVIDIA_DRIVER_CAPABILITIES=compute,video,utility - JELLYFIN_PublishedServerUrl=https://jellyfin.example.com volumes: - jellyfin_config:/config - jellyfin_cache:/cache - /srv/user/Desktop/MoviesAndShows/Movies:/media/movies-local:ro - /srv/user/Desktop/MoviesAndShows/Shows:/media/shows-local:ro - /mnt/storage/BulkStore/Movies:/media/movies:ro - /mnt/storage/BulkStore/Shows:/media/shows:ro restart: unless-stopped volumes: jellyfin_config: jellyfin_cache:
Usenet download client, and the primary download path for the media pipeline. Sonarr and Radarr hand NZBs to SABnzbd, which fetches the articles from a Usenet provider, repairs and unpacks them, then hands the finished files back for import.
| Service | Image | Container | Network |
|---|---|---|---|
| sabnzbd | lscr.io/linuxserver/sabnzbd:latest | sabnzbd | bridge |
| Image | lscr.io/linuxserver/sabnzbd:latest |
| Required env | PUID / PGID, TZ. Provider host, port, SSL and credentials are configured in the web UI on first run. |
| Common pitfall | Categories must exist in SABnzbd before Sonarr/Radarr will accept it as a download client. Create tv and movies first or the connection test fails with "category does not exist". |
| Vendor docs | sabnzbd.org/wiki · docs.linuxserver.io/images/docker-sabnzbd |
sabnzbd.ini on a bind mount. The web UI is gated by Cloudflare Access externally. There is no peer-to-peer surface at all: no inbound connections, no exposure of the host IP to other users./bulkstoredownload) that Sonarr, Radarr and the download client use, so no remote-path mapping is required in any of them. The incomplete directory is the write-heavy one. Putting it on SSD rather than spinning disk materially speeds up repair and unpack.services: sabnzbd: image: lscr.io/linuxserver/sabnzbd:latest container_name: sabnzbd restart: unless-stopped environment: - PUID=1000 - PGID=1000 - TZ=America/New_York volumes: - /srv/user/docker/sabnzbd/config:/config # Same container path Sonarr/Radarr/Downloader use, so completed # downloads need no remote-path mapping. - /mnt/storage/BulkStore/Downloads:/bulkstoredownload ports: - "8091:8080"
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. The kill switch is not a script watching the VPN. If gluetun dies the container simply has no network interface. Nothing to fail open.: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
Watches the *arr queues for completed downloads that arrived as multi-part archives, extracts them, and cleans up the extracted files once the import succeeds.
| Service | Image | Container | Network |
|---|---|---|---|
| unpackerr | golift/unpackerr:latest | unpackerr | bridge |
| Image | golift/unpackerr:latest |
| Required env | UN_SONARR_0_URL / UN_SONARR_0_API_KEY (and the Radarr equivalents), UN_SONARR_0_PATHS_0 matching the download path. |
| Common pitfall | Path mismatch. Unpackerr must see the download directory at the same path the *arr service reports, or it silently finds nothing to extract. |
| Vendor docs | unpackerr.zip |
version: "3.9" services: unpackerr: image: golift/unpackerr:latest container_name: unpackerr user: 1000:1000 environment: - TZ=America/New_York - UN_DEBUG=false - UN_INTERVAL=2m - UN_START_DELAY=1m - UN_RETRY_DELAY=5m - UN_MAX_RETRIES=3 - UN_PARALLEL=2 - UN_FILE_MODE=0644 - UN_DIR_MODE=0755 - UN_SONARR_0_URL=http://10.0.0.10:8989 - UN_SONARR_0_API_KEY=6f170ebf50504509b391f607e773f59a - UN_SONARR_0_PATHS_0=/downloads - UN_SONARR_0_PATHS_1=/bulkstoredownload - UN_SONARR_0_PROTOCOLS=nzb - UN_SONARR_0_TIMEOUT=10s - UN_SONARR_0_DELETE_ORIG=false - UN_RADARR_0_URL=http://10.0.0.10:7878 - UN_RADARR_0_API_KEY=e480f738286f4a42bf5fae6267376756 - UN_RADARR_0_PATHS_0=/downloads - UN_RADARR_0_PATHS_1=/bulkstoredownload - UN_RADARR_0_PROTOCOLS=nzb - UN_RADARR_0_TIMEOUT=10s - UN_RADARR_0_DELETE_ORIG=false volumes: - /srv/user/Desktop/MoviesAndShows/Downloads:/downloads - /mnt/storage/BulkStore/Downloads:/bulkstoredownload 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
Statistics and watch-history dashboard for Jellyfin. The Jellyfin counterpart to Tautulli. Tracks playback sessions, most-watched content, and per-user activity.
| Service | Image | Container | Network |
|---|---|---|---|
| jellystat-db | postgres:16-alpine | jellystat-db | bridge |
| jellystat | cyfershepard/jellystat:latest | jellystat | bridge |
| Images | cyfershepard/jellystat:latest, postgres:16-alpine |
| Required env | POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB on both services, POSTGRES_IP pointing at the DB service name, and a JWT_SECRET for session signing. |
| Common pitfall | POSTGRES_IP must be the Docker service name, not localhost. The app and database are separate containers. |
| Vendor docs | github.com/CyferShepard/Jellystat |
version: "3.9" services: jellystat-db: image: postgres:16-alpine container_name: jellystat-db restart: unless-stopped environment: POSTGRES_USER: jellystat POSTGRES_PASSWORD: Redacted POSTGRES_DB: jellystat volumes: - jellystat_db_data:/var/lib/postgresql/data jellystat: image: cyfershepard/jellystat:latest container_name: jellystat restart: unless-stopped ports: - "8088:3000" environment: POSTGRES_USER: jellystat POSTGRES_PASSWORD: Redacted POSTGRES_IP: jellystat-db POSTGRES_PORT: 5432 POSTGRES_DB: jellystat JWT_SECRET: Redacted TZ: America/New_York depends_on: - jellystat-db volumes: jellystat_db_data:
A small purpose-built HTTP proxy. Jellyseerr's API returns pending requests with TMDB IDs but no human-readable titles; this service resolves them and serves a clean JSON feed that the dashboard widget can render directly.
| Service | Image | Container | Network |
|---|---|---|---|
| jsr-titles | python:3.14-alpine | jsr-titles | bridge |
| Image | python:3-alpine (stock runtime, script injected at start) |
| Required env | JELLYSEERR_URL, JELLYSEERR_KEY |
| Common pitfall | Embedding the script inline means a syntax error only surfaces at container start. Check logs after any edit. |
| Vendor docs | n/a (bespoke) |
/pending) and can neither approve requests nor modify anything.python:alpine image. No build step and no image to maintain. Results are cached for 60 seconds to avoid hammering Jellyseerr.version: "3.9" services: jsr-titles: image: python:3.14-alpine container_name: jsr-titles restart: unless-stopped labels: - 'wud.tag.include=^3\.\d+-alpine$$' ports: - "9999:9999" environment: - JELLYSEERR_URL=http://10.0.0.10:5055 - JELLYSEERR_KEY=MTc2MTUxNDA0MDAwM2ZkMWQwNjQ5LTliYTYtNDc5Yi05MTc0LWU0Y2Q5YmVmNzdkNw== command: > # inline Python proxy (~4 KB, base64-embedded) fetches Jellyseerr # pending requests and resolves TMDB titles, served on :9999 sh -c "echo <base64-script> | base64 -d > /app.py && exec python /app.py"
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:34-apache | nextcloud_app | bridge |
| redis | redis:7.2-alpine | nextcloud-redis | bridge |
| Images | nextcloud:34-apache (pin to major version), mariadb:11, redis:7.2-alpine |
| 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 (32→33→34…). 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 labels: - 'wud.tag.include=^11(\.\d+){0,2}$$' 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:34-apache container_name: nextcloud_app restart: unless-stopped labels: - 'wud.tag.include=^\d+-apache$$' 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 redis: image: redis:7.2-alpine container_name: nextcloud-redis restart: unless-stopped 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.37.1-alpine | vaultwarden | bridge |
| vaultwarden-backup | bruceforce/vaultwarden-backup:latest | vaultwarden_backup_tool | bridge |
| Image | vaultwarden/server:1.37.1-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.37.1-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.37.1-alpine container_name: vaultwarden restart: unless-stopped labels: - 'wud.tag.include=^\d+\.\d+\.\d+-alpine$$' 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:
A self-written link-sharing web application, deployed as the lab's example of a proper software delivery workflow: production and staging environments, Git-backed deployment, and automated database backups.
| Service | Image | Container | Network |
|---|---|---|---|
| db | postgres:16-alpine | ${INSTANCE:-links}-db | bridge |
| api | — | ${INSTANCE:-links}-api | bridge |
| web | — | ${INSTANCE:-links}-web | bridge |
| backup | prodrigestivill/postgres-backup-local:16-alpine | ${INSTANCE:-links}-backup | bridge |
| Images | built from source (Caddy + Node), postgres:16-alpine, prodrigestivill/postgres-backup-local:16-alpine |
| Required env | POSTGRES_PASSWORD, SESSION_SECRET, INSTANCE, WEB_PORT, FORWARDED_PROTO, COOKIE_SECURE |
| Common pitfall | Behind a tunnel the app sees HTTP internally while the browser used HTTPS. Set FORWARDED_PROTO=https and COOKIE_SECURE=true or secure session cookies are silently dropped and login appears to fail for no reason. |
| Vendor docs | n/a (bespoke) |
SESSION_SECRET; secrets are supplied through an .env file rather than being written into the compose, which is why this stack shows ${VAR} references where others show literals.staging, production follows main. The same compose file builds both environments; INSTANCE, WEB_PORT and the other variables differentiate them. Postgres is dumped nightly to the NAS with tiered retention (14 daily · 8 weekly · 6 monthly).services: db: image: postgres:16-alpine container_name: ${INSTANCE:-links}-db restart: unless-stopped environment: POSTGRES_USER: links POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: links volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U links"] interval: 5s timeout: 5s retries: 12 networks: [linksnet] api: build: ./server container_name: ${INSTANCE:-links}-api restart: unless-stopped environment: DATABASE_URL: Redacted SESSION_SECRET: ${SESSION_SECRET} ALLOW_SIGNUPS: ${ALLOW_SIGNUPS:-true} COOKIE_SECURE: ${COOKIE_SECURE:-false} ADMIN_USERNAMES: ${ADMIN_USERNAMES:-} depends_on: db: condition: service_healthy networks: [linksnet] web: build: . container_name: ${INSTANCE:-links}-web restart: unless-stopped environment: SITE_ADDRESS: ":80" FORWARDED_PROTO: ${FORWARDED_PROTO:-http} ports: - "${WEB_PORT:-8085}:80" depends_on: [api] volumes: - caddy_data:/data - caddy_config:/config networks: [linksnet] backup: image: prodrigestivill/postgres-backup-local:16-alpine container_name: ${INSTANCE:-links}-backup restart: unless-stopped environment: POSTGRES_HOST: db POSTGRES_DB: links POSTGRES_USER: links POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_EXTRA_OPTS: "-Z6" SCHEDULE: "0 0 2 * * *" BACKUP_KEEP_DAYS: 14 BACKUP_KEEP_WEEKS: 8 BACKUP_KEEP_MONTHS: 6 TZ: America/New_York volumes: - ${BACKUP_PATH:-/mnt/storage/NAS/linksbackup}:/backups depends_on: db: condition: service_healthy networks: [linksnet] volumes: pgdata: caddy_data: caddy_config: networks: linksnet: driver: bridge
Self-hosted PDF toolbox — merge, split, compress, convert, OCR, sign, redact, rotate, watermark. A local replacement for the ad-riddled online PDF sites.
| Service | Image | Container | Network |
|---|---|---|---|
| stirling-pdf | stirlingtools/stirling-pdf:2.14.3 | stirling-pdf | bridge |
| Image | stirlingtools/stirling-pdf (pin a version) |
| Required env | For the free, no-login experience on 2.x: DISABLE_ADDITIONAL_FEATURES=true and SECURITY_ENABLELOGIN=false. The older DOCKER_ENABLE_SECURITY is ignored by 2.x. |
| Common pitfall | Pulling :latest and getting the freemium build with a forced login and MFA prompt. |
| Vendor docs | docs.stirlingpdf.com |
DISABLE_ADDITIONAL_FEATURES=true + SECURITY_ENABLELOGIN=false. Worth knowing: the project moved to a freemium model in its 1.0/2.x rework — latest now forces a login and shows paid-tier prompts unless those two settings are set.services: stirling-pdf: image: stirlingtools/stirling-pdf:2.14.3 container_name: stirling-pdf restart: unless-stopped ports: - "8100:8080" environment: # 2.x: old DOCKER_ENABLE_SECURITY is ignored. These keep it login-free. - DISABLE_ADDITIONAL_FEATURES=true - SECURITY_ENABLELOGIN=false - LANGS=en_US - SYSTEM_DEFAULTLOCALE=en-US - TZ=America/New_York volumes: - /srv/user/docker/stirling/trainingData:/usr/share/tessdata - /srv/user/docker/stirling/configs:/configs - /srv/user/docker/stirling/customFiles:/customFiles - /srv/user/docker/stirling/logs:/logs healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"] interval: 60s timeout: 10s retries: 3 start_period: 90s
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 - /mnt/storage/NAS/config-backup:/source/configs:ro - /mnt/storage/NAS/nextcloud-backup:/source/nextcloud:ro - /mnt/storage/NAS/portainer-backup:/source/portainer:ro - /mnt/storage/NAS/linksbackup:/source/links:ro - /mnt/storage/NAS/linksbackup-staging:/source/links-staging: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 # ⬆️ ADD THESE TWO LINES 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
A tiny HAProxy that sits between apps and the Docker socket, exposing only a read-only allowlist of API endpoints. Dashboards and monitors that need to see container state connect to it instead of the raw socket.
| Service | Image | Container | Network |
|---|---|---|---|
| socket-proxy | tecnativa/docker-socket-proxy:latest | socket-proxy | bridge |
| Image | tecnativa/docker-socket-proxy:latest |
| Required env | Per-endpoint flags, e.g. CONTAINERS=1, IMAGES=1, and critically POST=0 to block all writes. |
| Common pitfall | With read_only: true the container needs a writable /tmp (tmpfs) or HAProxy can't write its generated config and crash-loops. |
| Vendor docs | github.com/Tecnativa/docker-socket-proxy |
/var/run/docker.sock is root-equivalent, so any app holding it can escape to the host. This proxy mounts the socket read-only and permits only CONTAINERS/IMAGES/INFO/VERSION/EVENTS/NETWORKS/VOLUMES reads; POST, EXEC, and container create/start are all denied. It runs read-only-rootfs on its own bridge network, plus a loopback-only port for host-networked consumers.tcp://socket-proxy:2375 instead of the socket file. After adding it, the only containers still holding the raw socket should be the proxy itself and Portainer (which legitimately needs full control). Several dashboards turned out not to use Docker at all and had the socket removed entirely.services: socket-proxy: image: tecnativa/docker-socket-proxy:latest container_name: socket-proxy restart: unless-stopped environment: - CONTAINERS=1 - IMAGES=1 - INFO=1 - VERSION=1 - EVENTS=1 - PING=1 - NETWORKS=1 - VOLUMES=1 - POST=0 - EXEC=0 - CONTAINERS_CREATE=0 - CONTAINERS_START=0 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro ports: - "127.0.0.1:2375:2375" networks: - socketproxy read_only: true tmpfs: - /run - /tmp networks: socketproxy: name: socketproxy driver: bridge
Modern dashboard with live service widgets (download queues, media now-playing, disk usage) rather than a static grid of links. Runs alongside Flame, which remains the plain launcher.
| Service | Image | Container | Network |
|---|---|---|---|
| homepage | ghcr.io/gethomepage/homepage:latest | homepage | bridge |
| Image | ghcr.io/gethomepage/homepage:latest |
| Required env | HOMEPAGE_ALLOWED_HOSTS must list every hostname used to reach it, or recent versions refuse the request outright. |
| Common pitfall | Widget API keys go in services.yaml in plaintext. Keep that file out of any public repository. |
| Vendor docs | gethomepage.dev |
services.yaml, widgets.yaml, settings.yaml, custom.css, custom.js. Because it is file-based it is trivially version-controlled. Unlike a dashboard whose layout lives in a database.version: "3.9" services: homepage: image: ghcr.io/gethomepage/homepage:latest container_name: homepage ports: - "7676:3000" group_add: - "984" volumes: - homepage_config:/app/config - homepage_icons:/app/public/icons - /mnt/storage/BulkStore:/mnt/bulkstore:ro - /mnt/merged:/mnt/merged:ro environment: - PUID=1000 - PGID=1000 - TZ=America/New_York - HOMEPAGE_ALLOWED_HOSTS=server.example.com,homepage.example.com,10.0.0.10:7676,localhost:7676 restart: unless-stopped volumes: homepage_config: homepage_icons:
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 - /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 ports: - "3001:3001" environment: - TZ=America/New_York
Lightweight host and container resource monitoring. CPU, memory, disk, network and temperatures, with history. Complements Uptime Kuma, which answers "is it up" rather than "is it healthy".
| Service | Image | Container | Network |
|---|---|---|---|
| beszel | henrygd/beszel:latest | beszel | bridge |
| beszel-agent | henrygd/beszel-agent:latest | beszel-agent | host |
| Images | henrygd/beszel:latest (hub), henrygd/beszel-agent:latest (agent) |
| Required env | Agent needs KEY (public key issued by the hub) and PORT; hub needs a persistent data volume. |
| Common pitfall | The agent needs /proc, /sys and the Docker socket mounted read-only to report container-level metrics. Without them it reports host stats only. |
| Vendor docs | beszel.dev |
version: "3.9" services: beszel: image: henrygd/beszel:latest container_name: beszel restart: unless-stopped ports: - "8090:8090" volumes: - beszel_data:/beszel_data - beszel_socket:/beszel_socket beszel-agent: image: henrygd/beszel-agent:latest container_name: beszel-agent restart: unless-stopped network_mode: host volumes: - beszel_agent_data:/var/lib/beszel-agent environment: - LISTEN=45876 - KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMgiAxUC665hVIuhdCma+UznlWL0F+71nRe/QNeyceaY - TOKEN=Redacted - HUB_URL=http://127.0.0.1:8090 - DOCKER_HOST=tcp://127.0.0.1:2375 volumes: beszel_data: beszel_socket: beszel_agent_data:
"What's Up Docker". Watches every running container against its upstream registry and reports when a newer image is published. Findings are pushed to ntfy.
| Service | Image | Container | Network |
|---|---|---|---|
| wud | ghcr.io/getwud/wud:latest | wud | bridge |
| Image | ghcr.io/getwud/wud:latest |
| Required env | WUD_WATCHER_LOCAL_CRON, WUD_WATCHER_LOCAL_WATCHBYDEFAULT, plus trigger config. For LinuxServer images: WUD_REGISTRY_LSCR_PUBLIC_USERNAME and ..._TOKEN (anonymous values are accepted). |
| Common pitfall | The field is USERNAME, not LOGIN. Also, without a wud.tag.include label per pinned container, WUD proposes whatever tag sorts highest. Beta builds and Windows images included. |
| Vendor docs | getwud.github.io/wud |
docker logs wud for failed to register after changing configuration.services: wud: image: ghcr.io/getwud/wud:latest container_name: wud restart: unless-stopped environment: - TZ=America/New_York # Watch via socket-proxy (read-only) instead of the raw docker socket - WUD_WATCHER_LOCAL_HOST=socket-proxy - WUD_WATCHER_LOCAL_PORT=2375 - WUD_WATCHER_LOCAL_CRON=0 6 * * * - WUD_WATCHER_LOCAL_WATCHBYDEFAULT=true - WUD_TRIGGER_NTFY_HOMELAB_URL=http://10.0.0.10:8093 - WUD_TRIGGER_NTFY_HOMELAB_TOPIC=homelab - WUD_TRIGGER_NTFY_HOMELAB_AUTH_TOKEN=Redacted - WUD_TRIGGER_NTFY_HOMELAB_PRIORITY=3 - WUD_TRIGGER_NTFY_HOMELAB_ONCE=true - WUD_TRIGGER_NTFY_HOMELAB_THRESHOLD=minor - WUD_REGISTRY_LSCR_PUBLIC_USERNAME=anonymous - WUD_REGISTRY_LSCR_PUBLIC_TOKEN=anonymous volumes: - /srv/user/docker/wud:/store ports: - "8094:3000" networks: - socketproxy networks: socketproxy: external: true name: socketproxy
Self-hosted push notification server. Monitoring and automation publish to topics; the phone app subscribes and delivers them as native push notifications.
| Service | Image | Container | Network |
|---|---|---|---|
| ntfy | binwiederhier/ntfy:latest | ntfy | bridge |
| Image | binwiederhier/ntfy:latest |
| Required config | server.yml with base-url, auth-file, auth-default-access: deny-all, and upstream-base-url: https://ntfy.sh for iOS. |
| Common pitfall | NTFY_PASSWORD=... ntfy user add reports success but does not actually set the password. Use ntfy user change-pass afterwards, or every login returns 401. |
| Vendor docs | docs.ntfy.sh |
auth-default-access: deny-all, so topics are private and require a token. An open ntfy instance is a spam relay. Publishers authenticate with access tokens rather than user credentials.upstream-base-url to https://ntfy.sh; without it Apple's push service is never invoked and notifications only arrive while the app is open. Users and tokens are managed with the ntfy user and ntfy token CLI inside the container.services: ntfy: image: binwiederhier/ntfy:latest container_name: ntfy restart: unless-stopped command: serve environment: - TZ=America/New_York volumes: - /srv/user/docker/ntfy/etc:/etc/ntfy - /srv/user/docker/ntfy/cache:/var/cache/ntfy - /srv/user/docker/ntfy/lib:/var/lib/ntfy ports: - "8093:80" healthcheck: test: ["CMD-SHELL", "wget -q --tries=1 -O - http://localhost:80/v1/health 2>/dev/null | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"] interval: 60s timeout: 10s retries: 3 start_period: 40s
Real-time Docker log viewer in the browser. Every container's logs in one place, live-streaming, with search and multi-container view.
| Service | Image | Container | Network |
|---|---|---|---|
| dozzle | amir20/dozzle:latest | dozzle | bridge |
| Image | amir20/dozzle:latest |
| Required env | DOCKER_HOST=tcp://socket-proxy:2375 to read via the proxy instead of mounting the socket. |
| Common pitfall | Mounting the raw socket by default. Point it at the socket-proxy so a compromise of the log viewer can't control Docker. |
| Vendor docs | dozzle.dev |
docker logs" habit. Portainer can show logs too, but one container at a time; Dozzle is the purpose-built version for watching everything at once.services: dozzle: image: amir20/dozzle:latest container_name: dozzle restart: unless-stopped ports: - "8101:8080" environment: - DOZZLE_NO_ANALYTICS=true - DOCKER_HOST=tcp://socket-proxy:2375 - TZ=America/New_York networks: - socketproxy healthcheck: test: ["CMD", "/dozzle", "healthcheck"] interval: 60s timeout: 10s retries: 3 start_period: 20s networks: socketproxy: external: true name: socketproxy
A small scheduled container that tars the config directories of the media apps (Sonarr / Radarr / Prowlarr / Bazarr / Tautulli / SABnzbd / Jellyseerr) to the NAS every night, with rotation.
| Service | Image | Container | Network |
|---|---|---|---|
| config-backup | alpine:latest | config-backup | bridge |
| Image | alpine:latest running a cron loop over a mounted backup script |
| Required config | Read-only mount of the config tree, a writable NAS destination, and a cron entry. |
| Common pitfall | Tarring a live SQLite DB can catch a mid-write state. Keeping the -wal/-shm files in the archive (or using the app's own backup) keeps it consistent. |
| Vendor docs | n/a (bespoke) |
.tar.gz was chosen over a heavier tool because at this size, transparency and a trivial restore beat dedup.services: config-backup: image: alpine:latest container_name: config-backup restart: unless-stopped environment: - TZ=America/New_York volumes: - /srv/user/docker:/src:ro - /mnt/storage/NAS/config-backup:/backup - /srv/user/docker/config-backup/backup.sh:/backup.sh:ro command: - /bin/sh - -c - "apk add --no-cache tar findutils tzdata >/dev/null 2>&1; echo '30 3 * * * /backup.sh' > /etc/crontabs/root; echo 'config-backup ready, cron 03:30 daily'; crond -f -l 8"
Watches the host mount table and sends a phone push (via ntfy) the moment a media drive unmounts, with a recovery notice when it returns.
| Service | Image | Container | Network |
|---|---|---|---|
| mount-monitor | alpine:latest | mount-monitor | bridge |
| Image | alpine:latest running a poll loop |
| Required config | Bind /proc read-only; ntfy URL + token; the list of mountpoints to watch. |
| Common pitfall | Checking a bind-mounted directory instead of /proc/1/mounts — a container bind won't reflect a later host unmount, so the monitor would go blind to the exact event it's meant to catch. |
| Vendor docs | n/a (bespoke) |
/proc only. Holds an ntfy token to publish alerts. No inbound surface.nofail in fstab means such a dropout produces no error anywhere; this turns it into an alert instead of a mystery./proc/1/mounts every 5 minutes for each expected mountpoint and alerts only on a state change, so it never spams. Reads the host mount table directly rather than a bind mount, so it sees the true live state regardless of mount propagation.services: mount-monitor: image: alpine:latest container_name: mount-monitor restart: unless-stopped environment: - TZ=America/New_York volumes: - /proc:/hostproc:ro - /srv/user/docker/mount-monitor/monitor.sh:/monitor.sh:ro command: - /bin/sh - -c - "apk add --no-cache curl >/dev/null 2>&1; exec sh /monitor.sh"
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
Small self-hosted utility exposed on its own subdomain.
| Service | Image | Container | Network |
|---|---|---|---|
| trek | mauriceboe/trek:latest | trek | bridge |
| Image | mauriceboe/trek:latest |
| Required env | TZ |
| Common pitfall | Publishing a service through the tunnel without adding a matching Access policy. The hostname works immediately and the missing gate is easy to overlook. |
| Vendor docs | upstream project page |
version: "3.9" services: trek: image: mauriceboe/trek:latest container_name: trek restart: unless-stopped ports: - "8089:3000" environment: - NODE_ENV=production - APP_URL=https://trek.example.com - ENCRYPTION_KEY=Redacted - TZ=America/New_York volumes: - trek_data:/app/data - trek_uploads:/app/uploads volumes: trek_data: trek_uploads:
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.
An honest list. Some of these have been open for months; a homelab is never finished, and pretending otherwise would make the rest of this document less credible.
Sonarr and Radarr are supposed to hardlink imports so the download and the library copy share the same bytes. Here they silently fall back to copying, because downloads and the library can land on different underlying drives in the merged pool and a hardlink cannot cross filesystems. This quietly duplicated roughly 3 TB before an audit caught it. Cleaned up since, but the root cause (pool create-policy) is still not fixed, so it will slowly regrow.
Portainer needs it. The dashboard, the uptime monitor and the update watcher do not. Read-only or not, socket access is effectively root on the host. The plan is a docker-socket-proxy in front of the two that only read container state. Known, not yet done.
No Redis or APCu configured, which is the usual reason it feels slow. Redis is already running for Docmost, so this is a small change I keep not getting to.
Vaultwarden and the Links database back up nightly. The Sonarr/Radarr/Prowlarr configs. Quality profiles, custom formats, indexer setup, history. Do not, and that's the ~1 GB I'd actually miss. There's 3.4 TB free on the NAS; the only reason is time.
dperson/samba is abandoned. Moving to crazymax/samba with guest ok = no
is planned, not urgent. It's LAN-only.
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