← back to portfolio
/// HOMELAB · DOCUMENTATION & SETUP GUIDE

[Homelab]_

Self-Hosted Infrastructure
Documentation & Setup Guide

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

31
Stacks
51
Containers
0
Inbound ports
31 GB
RAM
DockerComposePortainerCloudflare Zero TrustProtonVPNNextcloudVaultwardenPlexPostgreSQLMariaDBRedisAdGuardDuplicatiLinux
v4.0 · public guide · August 2026
Docker 29.2.1

00.Overview

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.

What this lab is

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.

31
Compose stacks
51
Containers
12
vCPUs
31 GB
Host memory

Who this document is for

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.

How this document is organized

A note on redaction

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.

TC.Contents

Front matter
00.Overview
2
Part I. Foundations
01.Design philosophy & principles
4
02.Prerequisites
5
Part II. Architecture
03.Network topology
6
04.Trust boundaries
7
05.Data flow
8
06.Stack inventory
9
Part III. Setup foundations
07.Setting up the host
10
08.Cloudflare Tunnel + Zero Trust
11
Part IV. Stack guide
09.mediastacks · Media
12
10.jellyfin · Media
13
11.sabnzbd · Media
14
12.downloader-vpn · Media
15
13.unpackerr · Media
16
14.jellyseerr · Media
17
15.tautulli · Media
18
16.jellystat · Media
19
17.jsr-titles · Media
20
18.nextcloud-mariadb · Productivity
21
19.docmost · Productivity
22
20.vaultwarden · Productivity
23
21.links · Productivity
24
22.stirling-pdf · Productivity
25
23.samba-nas · Productivity
26
24.duplicati · Productivity
27
25.cloudflare-tunnel · Network
28
26.adguard · Network
29
27.socket-proxy · Network
30
28.homepage · Tools
31
29.flame-dashboard · Tools
32
30.uptime-kuma · Tools
33
31.beszel · Tools
34
32.wud · Tools
35
33.ntfy · Tools
36
34.dozzle · Tools
37
35.config-backup · Tools
38
36.mount-monitor · Tools
39
37.code-server · Tools
40
38.trek · Tools
41
Part V. Cross-cutting patterns
23.External access · VPN isolation · Backups · Segmentation · Updates · Monitoring · Secrets
42
Appendix
A1.Image inventory
44
A2.Volume inventory
45
A3.Network inventory
46
A4.Glossary
47
A5.Technologies
48

01.Design Philosophy & Principles

Five principles guide every architectural decision in this environment. They are the product of operational experience. Most were learned through previous failures.

1 · Everything is a declarative compose stack

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.

2 · Public access is identity-gated, never port-exposed

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.

3 · High-risk traffic is structurally isolated

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.

4 · Backups are tiered and decoupled

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.

5 · Storage tiers are explicit and honest

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.

02.Prerequisites

What you need to deploy a similar environment.

Hardware

Accounts

Software prerequisites on the host

Knowledge

What is NOT required

03.Network Topology

⊕ Internet CLOUDFLARE EDGE Zero Trust Access · Service Tokens · WAF *.example.com cloudflared outbound tunnel 📱 LAN clients phones · laptops · TVs AdGuard DNS sinkhole · :53 DOCKER HOST 10.0.0.10 · 12 vCPU · 31 GB RAM · RTX 3060 Ti MEDIA mediastacks plex · sonarr · radarr bazarr · prowlarr · notifiarr jellyfin second media front library mounted ro sabnzbd usenet · preferred single TLS connection downloader-vpn P2P fallback via VPN namespace jellyseerr tautulli jellystat unpackerr · jsr-titles . background helpers, no UI PROTONVPN via gluetun namespace port-forwarded · UI :8080 usenet egress is direct TLS. no VPN namespace required PRODUCTIVITY nextcloud docmost vaultwarden samba-nas links · links-staging caddy + node + postgres · git-backed auto-deploy duplicati nightly tarball → backup sidecar → duplicati → off-host independent failure domains, decoupled retention TOOLS & DASHBOARD portainer homepage flame uptime-kuma beszel code-server wud image-update watch → ntfy ntfy push notifications → phone trek STORAGE 📀 Local SSD /srv/user/docker 💽 Bulk store /mnt/storage/BulkStore (deep media archive) 💾 NAS share /mnt/storage/NAS (backups · sync data · vault cache) All volumes bind-mounted from host. Containers are disposable; data lives on the storage layer. LEGEND CF tunnel VPN tunnel backup flow LAN traffic 31 stacks · 51 containers
Top-down view: traffic flow from internet through Cloudflare to the host, with service clusters by category.

04.Trust Boundaries

TRUST BOUNDARIES & AUTHENTICATION FLOW ZONE 0 · UNTRUSTED INTERNET Anyone on the public internet. Assumed hostile by default. Unknown user ZONE 1 · CLOUDFLARE EDGE (authenticator) First trust boundary. Identity is asserted here; downstream services trust the assertion. Access policies per-app, identity-aware Service tokens for automation WAF + Rate limits L7 protection ↗ Allow: authenticated identity, valid session, service token ✕ Deny: anonymous, expired, policy mismatch, geo block, rate limit ZONE 2 · CLOUDFLARE TUNNEL (transport) Outbound-only persistent connection from homelab to Cloudflare. No inbound ports on the home router. cloudflared container ZONE 3 · DOCKER HOST (trusted infrastructure) Single Linux host. All container traffic enters here via the tunnel, AdGuard, or LAN. ZONE 4A · Containers (default trust) Most stacks. Reachable from LAN unauthenticated; authenticated when accessed via Cloudflare Access. nextcloud vaultwarden jellyseerr tautulli *arr (sonarr, radarr, bazarr, prowlarr) → Each can have its own Cloudflare Access policy ZONE 4B · Network-isolated downloader. Joined to gluetun's namespace. Cannot reach internet except through ProtonVPN. gluetun downloader ZONE 4C · Database isolation DB containers on private bridge networks. Only the paired app container can reach them. nextcloud_db docmost db CONTROLS SUMMARY 1. Cloudflare Access (identity-aware, per-app policies) Primary external authn. Email-based or service-token. Blocks unknown identities at the edge. 2. Application-layer auth (Vaultwarden 2FA, *arr API keys, Nextcloud MFA) Defense in depth. Even if Cloudflare is misconfigured, the app still requires authentication. 3. Network namespace isolation (gluetun kill switch) Structural. Downloader has no route to the internet outside the VPN. No runtime check to fail. 4. Container network isolation (private bridges for DBs) Database ports never exposed to the host; lateral movement contained even if an app is compromised.
Trust zones from internet down to per-container isolation. Identity is asserted at the edge and propagated inward.

05.Data Flow · Media & Backup

DATA FLOWS MEDIA PIPELINE Request → search → fetch (via VPN) → import → playback → observe User request phone / web → jellyseerr jellyseerr approve · forward sonarr · radarr add to queue prowlarr index search ⊕ VPN ISOLATION ZONE (ProtonVPN via gluetun) gluetun VPN tunnel · port fwd downloader network_mode: service:gluetun all traffic routed through ProtonVPN namespace send job 📂 STORAGE LAYER (bind-mounted into every relevant container) Local SSD: working set (current library) Bulk NAS: deep archive (older movies and shows) written plex scan · transcode · serve client devices TV · phone · web tautulli read-only observability read session data RPO: minutes (cached) · RTO: container recreate + scan BACKUP PIPELINE (two-tier, decoupled) Source services → on-host sidecar dump → NAS cache → Duplicati encrypted snapshot → off-host TIER 1 · Application-layer dump vaultwarden SQLite in named volume vaultwarden-backup sidecar cron 30 2 * * * · tar.xz NAS cache /Vaultwarden_Backup_Cache RO mount write TIER 2 · Encrypted, deduplicated, off-host NAS cache source (mounted RO) duplicati dedup · encrypt · schedule NAS · Duplicati_Backups destination (write only) FAILURE DOMAIN ISOLATION Tier 1 failure (sidecar broken): vault still works, Duplicati still has yesterday's cache. Tier 2 failure (Duplicati broken): local backups still produced nightly. Source corruption: read-only mounts prevent the backup process from propagating damage. Destination corruption: separate NAS path means cache + destination are not the same on-disk location. RPO Vault: 24 hours · General: 24-48 hours RTO Vault: minutes · General: hours
Two data pipelines: media (request → VPN-isolated fetch → storage → playback) and backup (two-tier with decoupled failure domains).

06.Stack Inventory

Every compose stack on the host, grouped by category.

StackCategoryServicesPortsPublic hostname
mediastacksMediaplex, sonarr, radarr, prowlarr, bazarr, notifiarr8989, 7878, 9696, 6767, 5454sonarr.example.com · radarr.example.com · prowlarr.example.com · bazarr.example.com · plex.example.com
jellyfinMediajellyfinjellyfin.example.com
sabnzbdMediasabnzbd8091sab.example.com
downloader-vpnMediagluetun, downloader8080, 8090, 8090, 8888qbit.example.com
unpackerrMediaunpackerr(no UI. Background worker)
jellyseerrMediajellyseerr5055jelly.example.com
tautulliMediatautulli8181plexdash.example.com
jellystatMediajellystat-db, jellystat8088jellydash.example.com
jsr-titlesMediajsr-titles9999(LAN only · :9999)
nextcloud-mariadbProductivitydb, app, redis8083cloud.example.com
docmostProductivitydocmost, db, redis3000docmost.example.com
vaultwardenProductivityvaultwarden, vaultwarden-backup8087vaultwarden.example.com
linksProductivitydb, api, web, backup${WEB_PORTlinks.example.com · links2.example.com (staging)
stirling-pdfProductivitystirling-pdf8100pdf.example.com
samba-nasProductivitysamba(LAN only · SMB)
duplicatiProductivityduplicati8200backup.example.com
cloudflare-tunnelNetworkcloudflared(meta. Provides the tunnel)
adguardNetworkadguardadguard.example.com
socket-proxyNetworksocket-proxy127.0.0.1(internal. No UI)
homepageToolshomepage7676server.example.com
flame-dashboardToolsflame5005example.com (apex)
uptime-kumaToolsuptime-kuma3001status.example.com
beszelToolsbeszel, beszel-agent8090beszel.example.com
wudToolswud8094(LAN only · :8094)
ntfyToolsntfy8093ntfy.example.com
dozzleToolsdozzle8101logs.example.com
config-backupToolsconfig-backup(no UI. Scheduled job)
mount-monitorToolsmount-monitor(no UI. Background worker)
code-serverToolscode-server8443code.example.com
trekToolstrek8089trek.example.com

07.Setting Up the Host

The plumbing that everything else assumes.

1 · Install Docker

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.

2 · Install Portainer

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.

3 · Storage layout convention

This lab uses a consistent path convention so every service's bind mounts are predictable:

Adopting the same convention across stacks means recovery procedures are uniform: re-mount the storage, reinstate the compose, start the stack. Done.

4 · PUID / PGID convention

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.

08.Cloudflare Tunnel + Zero Trust

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.

Why this matters

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.

High-level steps

  1. Sign up for Cloudflare (free), add your domain, switch nameservers.
  2. In the Zero Trust dashboard (one.dash.cloudflare.com) create a Tunnel.
  3. Copy the tunnel token. You'll feed it to the cloudflared container.
  4. In the same UI, define ingress rules: service.example.comhttp://localhost:<port>.
  5. For each public hostname, attach an Access policy under Access → Applications: typically "Email matches your@email.com".
  6. Deploy the cloudflared container on your host with the token (see below).

The cloudflared compose stack

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

Key gotchas

Vendor docs

🎬

mediastacks

Media

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

Image(s)
golift/notifiarr:latest · lscr.io/linuxserver/bazarr:latest · lscr.io/linuxserver/prowlarr:latest · lscr.io/linuxserver/radarr:latest · lscr.io/linuxserver/sonarr:latest · plexinc/pms-docker
Public hostname
sonarr.example.com · radarr.example.com · prowlarr.example.com · bazarr.example.com · plex.example.com
Network mode
bridge, host
Exposed ports
5454, 6767, 7878, 8989, 9696
Services
6
Volumes
24
Services
ServiceImageContainerNetwork
plexplexinc/pms-dockerplexhost
sonarrlscr.io/linuxserver/sonarr:latestsonarrbridge
radarrlscr.io/linuxserver/radarr:latestradarrbridge
prowlarrlscr.io/linuxserver/prowlarr:latestprowlarrbridge
bazarrlscr.io/linuxserver/bazarr:latestbazarrbridge
notifiarrgolift/notifiarr:latestnotifiarrbridge
Setup essentials
Imagesplexinc/pms-docker, lscr.io/linuxserver/{sonarr,radarr,bazarr,prowlarr}:latest, golift/notifiarr:latest
Required envPUID / PGID (host user), TZ, PLEX_CLAIM (get from plex.tv/claim within 4 minutes of first run)
Common pitfallVolumes 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 docsplex.tv/about/plex-app/plex-media-server · docs.linuxserver.io
Security posture
Plex runs in host network mode for GDM/DLNA discovery. Shares the host network stack. The *arr services bind to local ports and are reachable only via LAN or through Cloudflare-Access-protected subdomains. Indexer credentials live in Prowlarr's encrypted config DB on disk. No internet-exposed endpoints on this stack.
Design decisions
A single compose for the entire pipeline keeps service discovery trivial. Every container resolves every other on the shared default network. Plex's host-mode gives optimal direct-play performance for LAN clients. Media lives on two storage tiers (local SSD working set + NAS deep archive), both mounted into every relevant service.
Operational notes
Config persists on host bind mounts under /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.
Configuration (cleaned · secrets redacted)
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
🪼

jellyfin

Media

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

Image(s)
jellyfin/jellyfin:latest
Public hostname
jellyfin.example.com
Network mode
host
Exposed ports
(host network or none)
Services
1
Volumes
6
Services
ServiceImageContainerNetwork
jellyfinjellyfin/jellyfin:latestjellyfinhost
Setup essentials
Imagejellyfin/jellyfin:latest
Required envTZ; 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 pitfallDon'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 docsjellyfin.org/docs
Security posture
Local user accounts independent of Plex (Jellyfin has no central cloud account). Media volumes mounted read-only. Server cannot modify or delete underlying files. Hardware transcoding shares the same NVIDIA runtime as Plex on the host.
Design decisions
Defense in depth for the media library. A second independent service reading the same files. Different bug surface than Plex, different auth flow, no Plex account dependency. Insurance that costs ~200 MB of RAM at idle.
Operational notes
Config and cache live in named Docker volumes (managed by the engine. No host directory perms to worry about). Host networking for client discovery. Image bumps land every 2–4 weeks; upgrades are non-breaking within a major version.
Configuration (cleaned · secrets redacted)
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:
📰

sabnzbd

Media

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.

Image(s)
lscr.io/linuxserver/sabnzbd:latest
Public hostname
sab.example.com
Network mode
bridge
Exposed ports
8091
Services
1
Volumes
2
Services
ServiceImageContainerNetwork
sabnzbdlscr.io/linuxserver/sabnzbd:latestsabnzbdbridge
Setup essentials
Imagelscr.io/linuxserver/sabnzbd:latest
Required envPUID / PGID, TZ. Provider host, port, SSL and credentials are configured in the web UI on first run.
Common pitfallCategories 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 docssabnzbd.org/wiki · docs.linuxserver.io/images/docker-sabnzbd
Security posture
No inbound exposure. SABnzbd only makes outbound TLS connections to the provider. Provider credentials and the API key live in 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.
Design decisions
Usenet became the preferred path after living with both: a single outbound TLS connection at full line speed, nothing to maintain afterward. A peer-to-peer client is kept as a secondary path behind the VPN namespace.
Operational notes
Downloads land on the same container path (/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.
Configuration (cleaned · secrets redacted)
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-vpn

Media

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.

Image(s)
lscr.io/linuxserver/sabnzbd:latest · qmcgaw/gluetun
Public hostname
qbit.example.com
Network mode
bridge, service:gluetun
Exposed ports
8090, 8080, 8888
Services
2
Volumes
4
Services
ServiceImageContainerNetwork
gluetunqmcgaw/gluetungluetunbridge
downloaderlscr.io/linuxserver/sabnzbd:latestdownloaderservice:gluetun
Setup essentials
Imagesqmcgaw/gluetun, lscr.io/linuxserver/sabnzbd:latest
Required envVPN_SERVICE_PROVIDER, VPN_TYPE, OPENVPN_USER / OPENVPN_PASSWORD (or wireguard equivalents). For ProtonVPN add +pmp suffix to the username for port-forwarding.
Common pitfallForgetting 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 docsgithub.com/qdm12/gluetun-wiki · docs.linuxserver.io/images/docker-downloader
Security posture
Strongest network segmentation on the host. Downloader cannot leak traffic to the home IP because it has no other route. ProtonVPN port forwarding is enabled so peer connectivity survives the isolation; the forwarded port is opened on gluetun's internal firewall.
Design decisions
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.
Operational notes
gluetun ships frequent updates following ProtonVPN config changes. Pinned to :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.
Configuration (cleaned · secrets redacted)
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
📦

unpackerr

Media

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.

Image(s)
golift/unpackerr:latest
Public hostname
(no UI. Background worker)
Network mode
bridge
Exposed ports
(host network or none)
Services
1
Volumes
2
Services
ServiceImageContainerNetwork
unpackerrgolift/unpackerr:latestunpackerrbridge
Setup essentials
Imagegolift/unpackerr:latest
Required envUN_SONARR_0_URL / UN_SONARR_0_API_KEY (and the Radarr equivalents), UN_SONARR_0_PATHS_0 matching the download path.
Common pitfallPath mismatch. Unpackerr must see the download directory at the same path the *arr service reports, or it silently finds nothing to extract.
Vendor docsunpackerr.zip
Security posture
No network listener at all. It polls the *arr APIs outbound and works on the filesystem. Needs API keys for each *arr instance, held in its environment.
Design decisions
Releases still ship RAR-packed in 2026. Without extraction they sit in the queue forever because the importer cannot see a video file inside an archive.
Operational notes
Effectively invisible when it works. Logs are the only signal it is doing anything. Requires the same volume paths as the *arr services so extracted files land where the importer expects them.
Configuration (cleaned · secrets redacted)
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
🎟️

jellyseerr

Media

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.

Image(s)
fallenbagel/jellyseerr:latest
Public hostname
jelly.example.com
Network mode
bridge
Exposed ports
5055
Services
1
Volumes
1
Services
ServiceImageContainerNetwork
jellyseerrfallenbagel/jellyseerr:latestjellyseerrbridge
Setup essentials
Imagefallenbagel/jellyseerr:latest
Required envPUID / PGID, TZ. Plex / Sonarr / Radarr connections configured in the web UI on first run.
Common pitfallUse absolute volume paths (not ./jellyseerr-config). Relative paths resolve to the compose dir which can move.
Vendor docsdocs.jellyseerr.dev
Security posture
Local SQLite database holds user accounts (separate from Plex's account system) and request history. Public hostname gated by Cloudflare Access; LAN access is unauthenticated by default (acceptable for household-trust setting). No write access to media storage. Only talks to other services via APIs.
Design decisions
Separating requests from the *arr admin UIs lets non-technical household members participate without exposing the underlying automation. Its own port and subdomain enables independent Cloudflare Access policy.
Operational notes
Config is a single ~5 MB directory holding the SQLite DB, settings.json, VAPID keys for web push. Backed up via Duplicati along with the rest of /srv/user/docker. Updates every few weeks following upstream.
Configuration (cleaned · secrets redacted)
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
📊

tautulli

Media

Plex observability. Tracks watch history, currently-playing sessions, transcoding load, library growth, per-user activity. Pushes notifications on configured events.

Image(s)
tautulli/tautulli
Public hostname
plexdash.example.com
Network mode
bridge
Exposed ports
8181
Services
1
Volumes
1
Services
ServiceImageContainerNetwork
tautullitautulli/tautullitautullibridge
Setup essentials
Imagetautulli/tautulli:latest
Required envPUID / PGID, TZ. Plex API key configured via the UI after first launch.
Common pitfallTautulli needs network access to Plex; if Plex is on host network and Tautulli on bridge, use the host IP (not localhost) when configuring.
Vendor docstautulli.com
Security posture
Read-only consumer of Plex's API. Cannot modify media or user accounts. Local SQLite DB stores history. Web UI on :8181 gated by Cloudflare Access externally; LAN access by login.
Design decisions
Lives in its own stack so it can be restarted or upgraded independently of the *arr suite. Decoupled lifecycle.
Operational notes
Stateless companion to Plex. Restart-safe. The SQLite history DB is the only persistence. Backed up by Duplicati. Image bumps roughly quarterly.
Configuration (cleaned · secrets redacted)
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
📉

jellystat

Media

Statistics and watch-history dashboard for Jellyfin. The Jellyfin counterpart to Tautulli. Tracks playback sessions, most-watched content, and per-user activity.

Image(s)
cyfershepard/jellystat:latest · postgres:16-alpine
Public hostname
jellydash.example.com
Network mode
bridge
Exposed ports
8088
Services
2
Volumes
1
Services
ServiceImageContainerNetwork
jellystat-dbpostgres:16-alpinejellystat-dbbridge
jellystatcyfershepard/jellystat:latestjellystatbridge
Setup essentials
Imagescyfershepard/jellystat:latest, postgres:16-alpine
Required envPOSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB on both services, POSTGRES_IP pointing at the DB service name, and a JWT_SECRET for session signing.
Common pitfallPOSTGRES_IP must be the Docker service name, not localhost. The app and database are separate containers.
Vendor docsgithub.com/CyferShepard/Jellystat
Security posture
Two-container stack (app + PostgreSQL). Reads Jellyfin's API with an API key; cannot modify the library. Public hostname gated by Cloudflare Access. Database is not exposed to the host.
Design decisions
Tautulli for Jellyfin, basically. Jellyfin's own reporting is thin.
Operational notes
History lives in the Postgres volume and is the only thing worth preserving. The app itself is disposable. Jellyfin API key is configured through the UI on first run.
Configuration (cleaned · secrets redacted)
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:
🏷️

jsr-titles

Media

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.

Image(s)
python:3.14-alpine
Public hostname
(LAN only · :9999)
Network mode
bridge
Exposed ports
9999
Services
1
Volumes
0
Services
ServiceImageContainerNetwork
jsr-titlespython:3.14-alpinejsr-titlesbridge
Setup essentials
Imagepython:3-alpine (stock runtime, script injected at start)
Required envJELLYSEERR_URL, JELLYSEERR_KEY
Common pitfallEmbedding the script inline means a syntax error only surfaces at container start. Check logs after any edit.
Vendor docsn/a (bespoke)
Security posture
LAN-only, no authentication, read-only. Holds a Jellyseerr API key in its environment. Serves exactly one endpoint (/pending) and can neither approve requests nor modify anything.
Design decisions
Jellyseerr's API returns TMDB IDs; the dashboard widget needs titles. Sixty lines of Python in a stock image was less work than anything off the shelf, and there is nothing to update.
Operational notes
The script is embedded in the compose file as a base64 blob executed by a stock python:alpine image. No build step and no image to maintain. Results are cached for 60 seconds to avoid hammering Jellyseerr.
Configuration (cleaned · secrets redacted)
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"
☁️

nextcloud-mariadb

Productivity

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.

Image(s)
mariadb:11 · nextcloud:34-apache · redis:7.2-alpine
Public hostname
cloud.example.com
Network mode
bridge
Exposed ports
8083
Services
3
Volumes
3
Services
ServiceImageContainerNetwork
dbmariadb:11nextcloud_dbbridge
appnextcloud:34-apachenextcloud_appbridge
redisredis:7.2-alpinenextcloud-redisbridge
Setup essentials
Imagesnextcloud:34-apache (pin to major version), mariadb:11, redis:7.2-alpine
Required envMYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD for the DB; MYSQL_HOST: db on the app pointing at the DB service name.
Common pitfallNextcloud refuses to skip major versions on upgrade. Go one major at a time (32→33→34…). Always DB-dump before each step.
Vendor docsdocs.nextcloud.com
Security posture
Two-container split (app + DB) on a dedicated bridge network. MariaDB has no path from outside that network. MFA supported via TOTP and WebAuthn; admin enforces strong-password policy. External access flows through Cloudflare Tunnel + Access. Currently on Nextcloud 34, with a dedicated Redis container providing distributed memory cache and transactional file locking (APCu handles the local cache).
Design decisions
The database gets its own container and bridge network so even a compromised Nextcloud has no direct path to a DB port. Bind-mounting the data dir to NAS storage decouples backup strategy from container lifecycle.
Operational notes
Bulk file storage lives on NAS at /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.
Configuration (cleaned · secrets redacted)
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
📝

docmost

Productivity

Self-hosted Notion-style wiki and collaborative document editor. Used for personal notes, project documentation, and longer-form content.

Image(s)
docmost/docmost:latest · postgres:16-alpine · redis:7.2-alpine
Public hostname
docmost.example.com
Network mode
bridge
Exposed ports
3000
Services
3
Volumes
3
Services
ServiceImageContainerNetwork
docmostdocmost/docmost:latestdocmostbridge
dbpostgres:16-alpinedbbridge
redisredis:7.2-alpineredisbridge
Setup essentials
Imagesdocmost/docmost:latest, postgres:16-alpine, redis:7.2-alpine
Required envAPP_SECRET (generate a long random string), DATABASE_URL matching the DB env, REDIS_URL matching the redis service name.
Common pitfallRestart policies missing by default. Add restart: unless-stopped on all three services or Docmost stays down across host reboots.
Vendor docsdocmost.com/docs
Security posture
Three-tier stack (app + PostgreSQL + Redis). App secret and DB password inline in compose (recommended migration: .env file). No public access yet; intended for LAN-only or behind future SSO. Postgres exposes no port to the host.
Design decisions
Postgres + Redis is more than is needed for a single-user wiki but matches Docmost's recommended deployment and keeps the path to multi-user scaling open. Named volumes because the data shape is managed entirely by the app.
Operational notes
All state lives in three named Docker volumes (docmost / db_data / redis_data). Updates are docker compose pull && up -d; Docmost ships breaking changes occasionally so changelogs are worth checking.
Configuration (cleaned · secrets redacted)
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:
🔐

vaultwarden

Productivity

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.

Image(s)
bruceforce/vaultwarden-backup:latest · vaultwarden/server:1.37.1-alpine
Public hostname
vaultwarden.example.com
Network mode
bridge
Exposed ports
8087
Services
2
Volumes
3
Services
ServiceImageContainerNetwork
vaultwardenvaultwarden/server:1.37.1-alpinevaultwardenbridge
vaultwarden-backupbruceforce/vaultwarden-backup:latestvaultwarden_backup_toolbridge
Setup essentials
Imagevaultwarden/server:1.37.1-alpine (pin. Do not run :latest for a password manager)
Required envDOMAIN=https://vaultwarden.example.com (must match the exact public URL or WebAuthn breaks silently), SIGNUPS_ALLOWED=false, INVITATIONS_ALLOWED=false
Common pitfallForgetting DOMAIN → WebAuthn / passkey 2FA appears to work but cryptographic origin check fails. Leaving SIGNUPS_ALLOWED default (TRUE) → anyone reaching the URL can register.
Vendor docsgithub.com/dani-garcia/vaultwarden/wiki
Security posture
Highest-value target in the lab. Hardened: 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).
Design decisions
The backup sidecar pattern enforces backup discipline without depending on a separate orchestrator. Read-only mount prevents backups from ever corrupting source data. The alpine image variant trims ~40 MB and reduces CVE footprint.
Operational notes
SQLite DB in a named volume. The 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.
Configuration (cleaned · secrets redacted)
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:
🔗

links

Productivity

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.

Image(s)
postgres:16-alpine · prodrigestivill/postgres-backup-local:16-alpine
Public hostname
links.example.com · links2.example.com (staging)
Network mode
bridge
Exposed ports
${WEB_PORT
Services
4
Volumes
4
Services
ServiceImageContainerNetwork
dbpostgres:16-alpine${INSTANCE:-links}-dbbridge
api${INSTANCE:-links}-apibridge
web${INSTANCE:-links}-webbridge
backupprodrigestivill/postgres-backup-local:16-alpine${INSTANCE:-links}-backupbridge
Setup essentials
Imagesbuilt from source (Caddy + Node), postgres:16-alpine, prodrigestivill/postgres-backup-local:16-alpine
Required envPOSTGRES_PASSWORD, SESSION_SECRET, INSTANCE, WEB_PORT, FORWARDED_PROTO, COOKIE_SECURE
Common pitfallBehind 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 docsn/a (bespoke)
Security posture
Four-tier stack (Caddy front end, Node API, PostgreSQL, backup sidecar) on a dedicated bridge network. Only the web tier publishes a port. Sessions are signed with 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.
Design decisions
Running an application you wrote yourself is a different discipline from running packaged software: it needs a place to test changes before users see them, a deployment path that is not manual, and backups of data no vendor holds a copy of. Staging exists so a bad commit reaches a disposable environment first.
Operational notes
Portainer polls the GitHub repository and redeploys automatically when the tracked branch changes. Staging follows 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).
Configuration (cleaned · secrets redacted)
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
📄

stirling-pdf

Productivity

Self-hosted PDF toolbox — merge, split, compress, convert, OCR, sign, redact, rotate, watermark. A local replacement for the ad-riddled online PDF sites.

Image(s)
stirlingtools/stirling-pdf:2.14.3
Public hostname
pdf.example.com
Network mode
bridge
Exposed ports
8100
Services
1
Volumes
4
Services
ServiceImageContainerNetwork
stirling-pdfstirlingtools/stirling-pdf:2.14.3stirling-pdfbridge
Setup essentials
Imagestirlingtools/stirling-pdf (pin a version)
Required envFor 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 pitfallPulling :latest and getting the freemium build with a forced login and MFA prompt.
Vendor docsdocs.stirlingpdf.com
Security posture
Files are processed locally and never leave the host. Gated by Cloudflare Access externally; runs login-free on the LAN.
Design decisions
PDF wrangling is common and the online tools are a privacy hazard (you upload the document to a stranger's server). A local instance keeps sensitive documents on the host.
Operational notes
Pinned to a specific version and run login-free via 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.
Configuration (cleaned · secrets redacted)
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
📁

samba-nas

Productivity

Exposes the NAS over SMB so Windows / macOS / Linux clients on the LAN can mount it as a network drive.

Image(s)
dperson/samba
Public hostname
(LAN only · SMB)
Network mode
host
Exposed ports
(host network or none)
Services
1
Volumes
1
Services
ServiceImageContainerNetwork
sambadperson/sambasambahost
Setup essentials
Imagecrazymax/samba (recommended; dperson/samba is abandoned)
Required envUSERID / GROUPID matching the host owner of the bind path; an smb.conf mounted in or built via env vars defining the share.
Common pitfallPermissions: 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 docsgithub.com/crazy-max/docker-samba
Security posture
Single share, single user, host networking. The currently-used image (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.
Design decisions
SMB on a homelab is unfortunately still the only protocol every consumer OS speaks fluently. Stateless container + bind mount means image migration is low-risk.
Operational notes
Host networking so SMB broadcast and NetBIOS discovery work without bridge gymnastics. Container is essentially stateless. Share content lives in the bind-mounted directory.
Configuration (cleaned · secrets redacted)
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"
💾

duplicati

Productivity

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.

Image(s)
lscr.io/linuxserver/duplicati:latest
Public hostname
backup.example.com
Network mode
bridge
Exposed ports
8200
Services
1
Volumes
8
Services
ServiceImageContainerNetwork
duplicatilscr.io/linuxserver/duplicati:latestduplicatibridge
Setup essentials
Imagelscr.io/linuxserver/duplicati:latest
Required envPUID / PGID, TZ, SETTINGS_ENCRYPTION_KEY (strong random string. Losing it means losing access to all backups Duplicati creates).
Common pitfallStoring the encryption key only inside Duplicati itself. Keep a copy in Vaultwarden, otherwise loss of the container = loss of all backups.
Vendor docsdocs.linuxserver.io/images/docker-duplicati
Security posture
Encryption key inline in compose (recommended: move to .env). Source mounts READ-ONLY. Destination is a separate NAS path from any source. Backup-set corruption cannot take out source data.
Design decisions
Decoupled from any specific service: backs up whatever filesystem paths are mounted in. Sources are mounted read-only, so a bad backup config can waste space but cannot touch the originals.
Operational notes
Web UI on :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.
Configuration (cleaned · secrets redacted)
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
🌩️

cloudflare-tunnel

Network

Outbound-only tunnel to Cloudflare's edge. Exposes lab services on the public internet without opening any inbound ports on the home router.

Image(s)
cloudflare/cloudflared:latest
Public hostname
(meta. Provides the tunnel)
Network mode
bridge
Exposed ports
(host network or none)
Services
1
Volumes
0
Services
ServiceImageContainerNetwork
cloudflaredcloudflare/cloudflared:latestcloudflared-connectorbridge
Setup essentials
Imagecloudflare/cloudflared:latest
Required env--token <tunnel-token> in the command field. Obtain from Cloudflare Zero Trust dashboard after creating a tunnel.
Common pitfallRouting 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 docsdevelopers.cloudflare.com/cloudflare-one/connections/connect-networks
Security posture
Single most important security control in the lab. Combined with Cloudflare Access (Zero Trust) and per-app policies, every public service gets identity-aware authentication before requests ever reach the homelab. Tunnel credentials live in the cloudflared container's environment (token).
Design decisions
Outbound tunnels eliminate the entire class of attacks against an externally-exposed reverse proxy: no port to scan, no service banner to fingerprint, no edge to harden. The home router presents zero attack surface to the public internet.
Operational notes
Custom DNS (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.
Configuration (cleaned · secrets redacted)
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
🚫

adguard

Network

Network-wide DNS sinkhole. Blocks ads, trackers, and malware domains for every device on the LAN that uses it as a resolver.

Image(s)
adguard/adguardhome:latest
Public hostname
adguard.example.com
Network mode
host
Exposed ports
(host network or none)
Services
1
Volumes
2
Services
ServiceImageContainerNetwork
adguardadguard/adguardhome:latestadguardhost
Setup essentials
Imageadguard/adguardhome:latest
Required envTZ. First-run wizard at http://<host>:3000 sets admin credentials and binds DNS to :53.
Common pitfallConflict with systemd-resolved binding port 53 on the host. Disable / reconfigure resolved before starting the container.
Vendor docsgithub.com/AdguardTeam/AdGuardHome/wiki
Security posture
Acts as the LAN's primary DNS via the router's DHCP. Block-list maintenance is light-touch. The container's admin UI is on the host network and reachable from any LAN device. Gated by AdGuard's own admin password.
Design decisions
DNS-layer threat blocking is the highest-leverage defensive control on a LAN because it neutralizes most C2 callbacks, telemetry, and ad-based malware before traffic ever reaches the targeted host.
Operational notes
Host networking required to bind port 53. Config and work directories on the host filesystem so block-lists and query logs survive container recreation. Query log is useful for incident response. Quick visibility into what a suspicious device is resolving.
Configuration (cleaned · secrets redacted)
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
🧱

socket-proxy

Network

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.

Image(s)
tecnativa/docker-socket-proxy:latest
Public hostname
(internal. No UI)
Network mode
bridge
Exposed ports
127.0.0.1
Services
1
Volumes
1
Services
ServiceImageContainerNetwork
socket-proxytecnativa/docker-socket-proxy:latestsocket-proxybridge
Setup essentials
Imagetecnativa/docker-socket-proxy:latest
Required envPer-endpoint flags, e.g. CONTAINERS=1, IMAGES=1, and critically POST=0 to block all writes.
Common pitfallWith read_only: true the container needs a writable /tmp (tmpfs) or HAProxy can't write its generated config and crash-loops.
Vendor docsgithub.com/Tecnativa/docker-socket-proxy
Security posture
The most important local-privilege control on the host. Access to /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.
Design decisions
Defense in depth for the highest-value local target. A compromise of a socket-consuming app drops from "root on the host" to "can read a container list." The endpoint filtering is enforced by the proxy, not by trusting each app to behave.
Operational notes
Consumers reference 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.
Configuration (cleaned · secrets redacted)
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
🏠

homepage

Tools

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.

Image(s)
ghcr.io/gethomepage/homepage:latest
Public hostname
server.example.com
Network mode
bridge
Exposed ports
7676
Services
1
Volumes
4
Services
ServiceImageContainerNetwork
homepageghcr.io/gethomepage/homepage:latesthomepagebridge
Setup essentials
Imageghcr.io/gethomepage/homepage:latest
Required envHOMEPAGE_ALLOWED_HOSTS must list every hostname used to reach it, or recent versions refuse the request outright.
Common pitfallWidget API keys go in services.yaml in plaintext. Keep that file out of any public repository.
Vendor docsgethomepage.dev
Security posture
Configuration is YAML files on a bind mount, including API keys for the services it queries. Gated by Cloudflare Access. Widget API keys are read-only where the upstream service supports scoping.
Design decisions
Flame is just links. Homepage can show the download queue and what's currently playing without opening five tabs. Both are still running - migrating bookmarks never makes it to the top of the list.
Operational notes
Everything is declarative: 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.
Configuration (cleaned · secrets redacted)
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:
🔥

flame-dashboard

Tools

Personal homepage / app launcher. Customizable tile grid linking to every service in the lab.

Image(s)
pawelmalak/flame:latest
Public hostname
example.com (apex)
Network mode
bridge
Exposed ports
5005
Services
1
Volumes
2
Services
ServiceImageContainerNetwork
flamepawelmalak/flame:latestflamebridge
Setup essentials
Imagepawelmalak/flame:latest (or ghcr.io/gethomepage/homepage:latest as a modern replacement)
Required envPASSWORD_FILE pointing at a mounted file containing the admin password.
Common pitfallMounting the Docker socket read-write. Flame only needs read access for container discovery. Always use :ro.
Vendor docsgithub.com/pawelmalak/flame (project) · gethomepage.dev (recommended alternative)
Security posture
Read-only Docker socket mount. Minor surface. Password-protected access for write operations. The upstream project is effectively stalled. gethomepage.dev (Homepage) is a more actively-maintained alternative.
Design decisions
Originally chosen for its tile UX. Active replacement candidate: Homepage (modern, smart widgets, no required socket).
Operational notes
Single bind-mount data dir. Tile arrangement and password-protected access configured via the UI and persisted there.
Configuration (cleaned · secrets redacted)
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
📈

uptime-kuma

Tools

Self-hosted status page and uptime monitor. Pings every service in the lab; sends notifications on failure.

Image(s)
louislam/uptime-kuma:latest
Public hostname
status.example.com
Network mode
bridge
Exposed ports
3001
Services
1
Volumes
1
Services
ServiceImageContainerNetwork
uptime-kumalouislam/uptime-kuma:latestuptime-kumabridge
Setup essentials
Imagelouislam/uptime-kuma:latest
Required envTZ. First-run wizard at http://<host>:3001 sets admin credentials.
Common pitfallRunning 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 docsgithub.com/louislam/uptime-kuma/wiki
Security posture
Read-only Docker socket mount enables container-state monitoring. Notification webhooks (Discord / push services) configured in the UI and stored in the local DB.
Design decisions
If the monitor lies, nothing else can be trusted. It gets its own stack so a media redeploy can never take monitoring down with it.
Operational notes
All state in /opt/uptime-kuma/data. Updates are stable; image bumps roughly quarterly. The monitor list is the most valuable artifact. Export periodically.
Configuration (cleaned · secrets redacted)
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
📟

beszel

Tools

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

Image(s)
henrygd/beszel-agent:latest · henrygd/beszel:latest
Public hostname
beszel.example.com
Network mode
bridge, host
Exposed ports
8090
Services
2
Volumes
3
Services
ServiceImageContainerNetwork
beszelhenrygd/beszel:latestbeszelbridge
beszel-agenthenrygd/beszel-agent:latestbeszel-agenthost
Setup essentials
Imageshenrygd/beszel:latest (hub), henrygd/beszel-agent:latest (agent)
Required envAgent needs KEY (public key issued by the hub) and PORT; hub needs a persistent data volume.
Common pitfallThe agent needs /proc, /sys and the Docker socket mounted read-only to report container-level metrics. Without them it reports host stats only.
Vendor docsbeszel.dev
Security posture
Hub-and-agent split. The agent reads system metrics and talks only to the hub; the hub serves the web UI, gated by Cloudflare Access. No control-plane access. It observes and nothing more.
Design decisions
Uptime Kuma says a service answers. It does not say the host is swapping. That distinction mattered here: game servers once pushed 5+ GB into swap and every web UI crawled for days while every monitor stayed green. Resource history is what caught it.
Operational notes
Very low overhead compared with a Prometheus/Grafana stack. Metric history lives in the hub's data volume.
Configuration (cleaned · secrets redacted)
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:
🔄

wud

Tools

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

Image(s)
ghcr.io/getwud/wud:latest
Public hostname
(LAN only · :8094)
Network mode
bridge
Exposed ports
8094
Services
1
Volumes
1
Services
ServiceImageContainerNetwork
wudghcr.io/getwud/wud:latestwudbridge
Setup essentials
Imageghcr.io/getwud/wud:latest
Required envWUD_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 pitfallThe 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 docsgetwud.github.io/wud
Security posture
Read-only Docker socket mount for container discovery. Holds an ntfy token to publish notifications. Reports only. It never pulls or restarts anything, so a compromise cannot alter running workloads.
Design decisions
Deliberately not Watchtower. Auto-updating means a broken image takes the stack down while nobody is awake to notice. WUD just reports; updates get applied when someone is around to watch them land.
Operational notes
Registries must be explicitly enabled or the containers using them are silently never checked. They appear in the "no update" list, which looks healthy but means the opposite. Always check docker logs wud for failed to register after changing configuration.
Configuration (cleaned · secrets redacted)
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
🔔

ntfy

Tools

Self-hosted push notification server. Monitoring and automation publish to topics; the phone app subscribes and delivers them as native push notifications.

Image(s)
binwiederhier/ntfy:latest
Public hostname
ntfy.example.com
Network mode
bridge
Exposed ports
8093
Services
1
Volumes
3
Services
ServiceImageContainerNetwork
ntfybinwiederhier/ntfy:latestntfybridge
Setup essentials
Imagebinwiederhier/ntfy:latest
Required configserver.yml with base-url, auth-file, auth-default-access: deny-all, and upstream-base-url: https://ntfy.sh for iOS.
Common pitfallNTFY_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 docsdocs.ntfy.sh
Security posture
Configured with 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.
Design decisions
Alerts reach the phone without going through a third-party service that might rate-limit, change pricing, or disappear.
Operational notes
For iOS delivery a self-hosted instance must set 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.
Configuration (cleaned · secrets redacted)
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
🪵

dozzle

Tools

Real-time Docker log viewer in the browser. Every container's logs in one place, live-streaming, with search and multi-container view.

Image(s)
amir20/dozzle:latest
Public hostname
logs.example.com
Network mode
bridge
Exposed ports
8101
Services
1
Volumes
0
Services
ServiceImageContainerNetwork
dozzleamir20/dozzle:latestdozzlebridge
Setup essentials
Imageamir20/dozzle:latest
Required envDOCKER_HOST=tcp://socket-proxy:2375 to read via the proxy instead of mounting the socket.
Common pitfallMounting the raw socket by default. Point it at the socket-proxy so a compromise of the log viewer can't control Docker.
Vendor docsdozzle.dev
Security posture
Reads container state through the socket-proxy (read-only), not the raw Docker socket. Gated by Cloudflare Access externally.
Design decisions
Replaces the "SSH in and run 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.
Operational notes
Stateless and tiny (~10 MB). It is a live viewer, not a log store — it shows what Docker's json-file driver currently holds and does not retain or index history. For searchable history and alerting, a Loki/Grafana or SIEM tier is the separate tool.
Configuration (cleaned · secrets redacted)
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
🗄️

config-backup

Tools

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.

Image(s)
alpine:latest
Public hostname
(no UI. Scheduled job)
Network mode
bridge
Exposed ports
(host network or none)
Services
1
Volumes
3
Services
ServiceImageContainerNetwork
config-backupalpine:latestconfig-backupbridge
Setup essentials
Imagealpine:latest running a cron loop over a mounted backup script
Required configRead-only mount of the config tree, a writable NAS destination, and a cron entry.
Common pitfallTarring 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 docsn/a (bespoke)
Security posture
Read-only mount of the source config tree; write access only to its own backup destination. No network listener. Holds no credentials.
Design decisions
The *arr databases hold quality profiles, custom formats, indexer setups and watch history that represent weeks of tuning and are not stored by any vendor. Plain .tar.gz was chosen over a heavier tool because at this size, transparency and a trivial restore beat dedup.
Operational notes
Runs a daily cron (03:30), excludes regenerable caches (poster art, logs) so a full set is ~100 MB rather than 1 GB, and prunes tarballs older than 14 days. The output folder is itself swept offsite to Google Drive by Duplicati, so these configs get a local and an offsite copy.
Configuration (cleaned · secrets redacted)
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"
🛰️

mount-monitor

Tools

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.

Image(s)
alpine:latest
Public hostname
(no UI. Background worker)
Network mode
bridge
Exposed ports
(host network or none)
Services
1
Volumes
2
Services
ServiceImageContainerNetwork
mount-monitoralpine:latestmount-monitorbridge
Setup essentials
Imagealpine:latest running a poll loop
Required configBind /proc read-only; ntfy URL + token; the list of mountpoints to watch.
Common pitfallChecking 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 docsn/a (bespoke)
Security posture
Read-only view of the host's /proc only. Holds an ntfy token to publish alerts. No inbound surface.
Design decisions
Directly closes a failure that actually happened: a drive silently failing to mount at boot while the union filesystem quietly served half the library. nofail in fstab means such a dropout produces no error anywhere; this turns it into an alert instead of a mystery.
Operational notes
Checks /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.
Configuration (cleaned · secrets redacted)
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"
💻

code-server

Tools

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.

Image(s)
lscr.io/linuxserver/code-server:latest
Public hostname
code.example.com
Network mode
bridge
Exposed ports
8443
Services
1
Volumes
1
Services
ServiceImageContainerNetwork
code-serverlscr.io/linuxserver/code-server:latestcode-serverbridge
Setup essentials
Imagelscr.io/linuxserver/code-server:latest
Required envPUID / PGID, TZ, PASSWORD (or HASHED_PASSWORD) for web access.
Common pitfallPlaceholder /path/to/... in the example compose. Replace with a real persistent path before first launch or extensions and settings vanish on container recreate.
Vendor docsdocs.linuxserver.io/images/docker-code-server
Security posture
Password-gated workspace. Bind-mounted config dir. Recommended: front with Cloudflare Access if exposing externally (don't rely solely on code-server's built-in password).
Design decisions
Provides a consistent code environment reachable from any device. Phone in bed editing a compose file is the canonical use case.
Operational notes
Lightweight single-container stack from LinuxServer.io. Listens on :8443. Config dir mirrors a real user's home so settings survive recreate.
Configuration (cleaned · secrets redacted)
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
🚀

trek

Tools

Small self-hosted utility exposed on its own subdomain.

Image(s)
mauriceboe/trek:latest
Public hostname
trek.example.com
Network mode
bridge
Exposed ports
8089
Services
1
Volumes
2
Services
ServiceImageContainerNetwork
trekmauriceboe/trek:latesttrekbridge
Setup essentials
Imagemauriceboe/trek:latest
Required envTZ
Common pitfallPublishing a service through the tunnel without adding a matching Access policy. The hostname works immediately and the missing gate is easy to overlook.
Vendor docsupstream project page
Security posture
Single container, no database. Currently reachable without a Cloudflare Access policy in front. Flagged for review, as there is no clear reason for it to be ungated.
Design decisions
Kept in its own stack so its lifecycle is independent of anything else.
Operational notes
Stateless. Redeploy replaces it entirely.
Configuration (cleaned · secrets redacted)
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:

23.External Access

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.

24.VPN Isolation Pattern

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.

25.Backup Strategy

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.

26.Network Segmentation

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.

27.Update Workflow

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.

28.Monitoring

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.

29.Notification Flow

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.

30.Secrets Management

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.

31.Known Issues & Not Done Yet

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.

Hardlinks don't work across the storage pool

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.

Four containers can reach the Docker socket

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.

Nextcloud has no memory cache

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.

The *arr configs aren't backed up

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.

Samba is on a 2020 image with guest access on

dperson/samba is abandoned. Moving to crazymax/samba with guest ok = no is planned, not urgent. It's LAN-only.

A1.Image Inventory

Every container image referenced in this environment, grouped by stack.

mediastacks · plex → plexinc/pms-docker
mediastacks · sonarr → lscr.io/linuxserver/sonarr:latest
mediastacks · radarr → lscr.io/linuxserver/radarr:latest
mediastacks · prowlarr → lscr.io/linuxserver/prowlarr:latest
mediastacks · bazarr → lscr.io/linuxserver/bazarr:latest
mediastacks · notifiarr → golift/notifiarr:latest
jellyfin · jellyfin → jellyfin/jellyfin:latest
sabnzbd · sabnzbd → lscr.io/linuxserver/sabnzbd:latest
downloader-vpn · gluetun → qmcgaw/gluetun
downloader-vpn · downloader → lscr.io/linuxserver/sabnzbd:latest
unpackerr · unpackerr → golift/unpackerr:latest
jellyseerr · jellyseerr → fallenbagel/jellyseerr:latest
tautulli · tautulli → tautulli/tautulli
jellystat · jellystat-db → postgres:16-alpine
jellystat · jellystat → cyfershepard/jellystat:latest
jsr-titles · jsr-titles → python:3.14-alpine
nextcloud-mariadb · db → mariadb:11
nextcloud-mariadb · app → nextcloud:34-apache
nextcloud-mariadb · redis → redis:7.2-alpine
docmost · docmost → docmost/docmost:latest
docmost · db → postgres:16-alpine
docmost · redis → redis:7.2-alpine
vaultwarden · vaultwarden → vaultwarden/server:1.37.1-alpine
vaultwarden · vaultwarden-backup → bruceforce/vaultwarden-backup:latest
links · db → postgres:16-alpine
links · api → —
links · web → —
links · backup → prodrigestivill/postgres-backup-local:16-alpine
stirling-pdf · stirling-pdf → stirlingtools/stirling-pdf:2.14.3
samba-nas · samba → dperson/samba
duplicati · duplicati → lscr.io/linuxserver/duplicati:latest
cloudflare-tunnel · cloudflared → cloudflare/cloudflared:latest
adguard · adguard → adguard/adguardhome:latest
socket-proxy · socket-proxy → tecnativa/docker-socket-proxy:latest
homepage · homepage → ghcr.io/gethomepage/homepage:latest
flame-dashboard · flame → pawelmalak/flame:latest
uptime-kuma · uptime-kuma → louislam/uptime-kuma:latest
beszel · beszel → henrygd/beszel:latest
beszel · beszel-agent → henrygd/beszel-agent:latest
wud · wud → ghcr.io/getwud/wud:latest
ntfy · ntfy → binwiederhier/ntfy:latest
dozzle · dozzle → amir20/dozzle:latest
config-backup · config-backup → alpine:latest
mount-monitor · mount-monitor → alpine:latest
code-server · code-server → lscr.io/linuxserver/code-server:latest
trek · trek → mauriceboe/trek:latest
standalone · portainer → portainer/portainer-ce:2.44.0

A2.Volume Inventory

All bind mounts and named volumes per service.

mediastacks · /srv/user/docker/plex/config:/config
mediastacks · /srv/user/Desktop/MoviesAndShows/Movies:/movies
mediastacks · /srv/user/Desktop/MoviesAndShows/Shows:/shows
mediastacks · /mnt/storage/BulkStore/Movies:/bulkstoremovies
mediastacks · /mnt/storage/BulkStore/Shows:/bulkstoreshows
mediastacks · /srv/user/Desktop/MoviesAndShows/Downloads:/downloads
mediastacks · /mnt/storage/BulkStore/Downloads:/bulkstoredownload
mediastacks · /srv/user/docker/sonarr/config:/config
mediastacks · /srv/user/Desktop/MoviesAndShows/Shows:/tv
mediastacks · /mnt/storage/BulkStore/Shows:/bulkstoreshows
mediastacks · /srv/user/Desktop/MoviesAndShows/Downloads:/downloads
mediastacks · /mnt/storage/BulkStore/Downloads:/bulkstoredownload
mediastacks · /srv/user/docker/radarr/config:/config
mediastacks · /srv/user/Desktop/MoviesAndShows/Movies:/movies
mediastacks · /mnt/storage/BulkStore/Movies:/bulkstoremovies
mediastacks · /srv/user/Desktop/MoviesAndShows/Downloads:/downloads
mediastacks · /mnt/storage/BulkStore/Downloads:/bulkstoredownload
mediastacks · /srv/user/docker/prowlarr/config:/config
mediastacks · /srv/user/docker/bazarr/config:/config
mediastacks · /srv/user/Desktop/MoviesAndShows/Movies:/movies
mediastacks · /srv/user/Desktop/MoviesAndShows/Shows:/tv
mediastacks · /mnt/storage/BulkStore/Movies:/bulkstoremovies
mediastacks · /mnt/storage/BulkStore/Shows:/bulkstoreshows
mediastacks · /srv/user/docker/notifiarr/config:/config
jellyfin · jellyfin_config:/config
jellyfin · jellyfin_cache:/cache
jellyfin · /srv/user/Desktop/MoviesAndShows/Movies:/media/movies-local:ro
jellyfin · /srv/user/Desktop/MoviesAndShows/Shows:/media/shows-local:ro
jellyfin · /mnt/storage/BulkStore/Movies:/media/movies:ro
jellyfin · /mnt/storage/BulkStore/Shows:/media/shows:ro
sabnzbd · /srv/user/docker/sabnzbd/config:/config
sabnzbd · /mnt/storage/BulkStore/Downloads:/bulkstoredownload
downloader-vpn · /srv/user/docker/downloader/config:/config
downloader-vpn · /srv/user/Desktop/MoviesAndShows/Downloads:/downloads
downloader-vpn · /mnt/storage/BulkStore/Downloads:/bulkstoredownload
downloader-vpn · /mnt/storage/BulkStore/ManualDownload:/manualdownload
unpackerr · /srv/user/Desktop/MoviesAndShows/Downloads:/downloads
unpackerr · /mnt/storage/BulkStore/Downloads:/bulkstoredownload
jellyseerr · /srv/user/docker/jellyseerr/config:/app/config
tautulli · /srv/user/docker/tautulli:/config
jellystat · jellystat_db_data:/var/lib/postgresql/data
nextcloud-mariadb · /mnt/storage/NAS/NextCloud/db:/var/lib/mysql
nextcloud-mariadb · /mnt/storage/NAS/NextCloud/app:/var/www/html
nextcloud-mariadb · /mnt/storage/NAS/NextCloud/data:/var/www/html/data
docmost · docmost:/app/data/storage
docmost · db_data:/var/lib/postgresql/data
docmost · redis_data:/data
vaultwarden · vaultwarden_data:/data
vaultwarden · vaultwarden_data:/data:ro
vaultwarden · /mnt/storage/NAS/Vaultwarden_Backup_Cache:/backup
links · pgdata:/var/lib/postgresql/data
links · caddy_data:/data
links · caddy_config:/config
links · ${BACKUP_PATH:-/mnt/storage/NAS/linksbackup}:/backups
stirling-pdf · /srv/user/docker/stirling/trainingData:/usr/share/tessdata
stirling-pdf · /srv/user/docker/stirling/configs:/configs
stirling-pdf · /srv/user/docker/stirling/customFiles:/customFiles
stirling-pdf · /srv/user/docker/stirling/logs:/logs
samba-nas · /mnt/storage/NAS:/media/NAS
duplicati · /opt/duplicati/config:/config
duplicati · /mnt/storage/NAS/Duplicati_Backups:/destination
duplicati · /mnt/storage/NAS/Vaultwarden_Backup_Cache:/vaultwarden:ro
duplicati · /mnt/storage/NAS/config-backup:/source/configs:ro
duplicati · /mnt/storage/NAS/nextcloud-backup:/source/nextcloud:ro
duplicati · /mnt/storage/NAS/portainer-backup:/source/portainer:ro
duplicati · /mnt/storage/NAS/linksbackup:/source/links:ro
duplicati · /mnt/storage/NAS/linksbackup-staging:/source/links-staging:ro
adguard · /opt/adguard/work:/opt/adguardhome/work
adguard · /opt/adguard/conf:/opt/adguardhome/conf
socket-proxy · /var/run/docker.sock:/var/run/docker.sock:ro
homepage · homepage_config:/app/config
homepage · homepage_icons:/app/public/icons
homepage · /mnt/storage/BulkStore:/mnt/bulkstore:ro
homepage · /mnt/merged:/mnt/merged:ro
flame-dashboard · /opt/flame/data:/app/data
flame-dashboard · /opt/flame/secrets/password:/run/secrets/password:ro
uptime-kuma · /opt/uptime-kuma/data:/app/data
beszel · beszel_data:/beszel_data
beszel · beszel_socket:/beszel_socket
beszel · beszel_agent_data:/var/lib/beszel-agent
wud · /srv/user/docker/wud:/store
ntfy · /srv/user/docker/ntfy/etc:/etc/ntfy
ntfy · /srv/user/docker/ntfy/cache:/var/cache/ntfy
ntfy · /srv/user/docker/ntfy/lib:/var/lib/ntfy
config-backup · /srv/user/docker:/src:ro
config-backup · /mnt/storage/NAS/config-backup:/backup
config-backup · /srv/user/docker/config-backup/backup.sh:/backup.sh:ro
mount-monitor · /proc:/hostproc:ro
mount-monitor · /srv/user/docker/mount-monitor/monitor.sh:/monitor.sh:ro
code-server · /srv/user/docker/code-server/config:/config
trek · trek_data:/app/data
trek · trek_uploads:/app/uploads

A3.Network Inventory

Network modes and exposed ports per service.

mediastacks · plex → host
mediastacks · sonarr → port 8989:8989
mediastacks · radarr → port 7878:7878
mediastacks · prowlarr → port 9696:9696
mediastacks · bazarr → port 6767:6767
mediastacks · notifiarr → port 5454:5454
jellyfin · jellyfin → host
sabnzbd · sabnzbd → port 8091:8080
downloader-vpn · gluetun → port 8080:8080" # Downloader Web UI
downloader-vpn · gluetun → port 8090:8090" # download peer port (TCP)
downloader-vpn · gluetun → port 8090:8090/udp" # download peer port (UDP)
downloader-vpn · gluetun → port 8888:8888/tcp" # Gluetun Web UI (uncomment if you want it)
downloader-vpn · downloader → service:gluetun
jellyseerr · jellyseerr → port 5055:5055
tautulli · tautulli → port 8181:8181
jellystat · jellystat → port 8088:3000
jsr-titles · jsr-titles → port 9999:9999
nextcloud-mariadb · app → port 8083:80
docmost · docmost → port 3000:3000
vaultwarden · vaultwarden → port 8087:80
links · web → port ${WEB_PORT:-8085}:80
stirling-pdf · stirling-pdf → port 8100:8080
samba-nas · samba → host
duplicati · duplicati → port 8200:8200
adguard · adguard → host
socket-proxy · socket-proxy → port 127.0.0.1:2375:2375
homepage · homepage → port 7676:3000
flame-dashboard · flame → port 5005:5005
uptime-kuma · uptime-kuma → port 3001:3001
beszel · beszel → port 8090:8090
beszel · beszel-agent → host
wud · wud → port 8094:3000
ntfy · ntfy → port 8093:80
dozzle · dozzle → port 8101:8080
code-server · code-server → port 8443:8443
trek · trek → port 8089:3000

A4.Glossary

Terms used in this document, briefly defined.

Bind mount
A directory on the Docker host mounted directly into a container. Survives container deletion.
Cloudflare Access
Identity-aware authentication layer in front of public hostnames. Evaluates per-application policies before forwarding traffic.
Cloudflare Tunnel
An outbound-only persistent connection from a private network to Cloudflare's edge. Eliminates the need for inbound router ports.
Compose file
A declarative YAML manifest of one or more containers, their networking, volumes, and dependencies.
Kill switch (VPN)
A control that ensures a service cannot send traffic outside of its VPN tunnel. Can be enforced by firewall rule or, more reliably, by network namespace topology.
Namespace (network)
A Linux kernel construct providing an isolated network stack. Sharing one between containers is the basis of the VPN kill-switch pattern.
Named volume
A Docker-managed storage location, opaque to the host filesystem layout. Used when the data shape is owned by the application.
Reverse proxy
A server that accepts inbound requests and forwards them to one or more backends. In this environment, Cloudflare's edge plays this role.
Sidecar
A secondary container deployed alongside a primary one to provide auxiliary functionality (backups, logging, proxying).
Stack
A logically-grouped set of containers, typically defined by one compose file and managed as a unit.
Zero Trust
A security model that assumes no implicit trust based on network location. Every request is authenticated and authorized at access time.

A5.Technologies

Technologies and patterns documented in this environment.

DockerDocker ComposePortainerLinuxBashPythonCloudflare TunnelCloudflare Zero TrustService TokensWAFProtonVPNOpenVPNWireGuardgluetunAdGuard HomeDNS sinkholingPlexSonarrRadarrBazarrProwlarrTautulliJellyseerrNotifiarrNextcloudMariaDBPostgreSQLRedisSQLiteVaultwardenBitwarden-compatibleDocmostWiki / collaborative editingDuplicatiEncrypted backupsDeduplicated backupsSamba / SMBcode-serverUptime KumaCloudflaredBridge networksHost networkingWebAuthn2FA / TOTPTLSDownloaderNetwork namespaces

> end of document