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

[Homelab]_

Self-Hosted Infrastructure
Documentation & Setup Guide

>cat README.md

Reference manual for a 14-stack, 27-container self-hosted Docker environment. Every service explained — what it does, why it's here, how it's configured, and the essential setup notes you need to reproduce the pattern. Identity-gated external access, structurally-isolated network paths, tiered encrypted backups.

14
Stacks
27
Containers
0
Inbound ports
31 GB
RAM
DockerComposePortainerCloudflare Zero TrustProtonVPNNextcloudVaultwardenPlexPostgreSQLMariaDBRedisAdGuardDuplicatiLinux
v4.0 · public guide · May 2026
Docker 29.2.1

00.Overview

This document is a public reference guide for a personal homelab — a single-host Docker environment running fourteen compose stacks across twenty-seven containers. It serves as a personal cloud, media library, password manager, wiki, network DNS sinkhole, and small game-server platform.

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.

14
Compose stacks
27
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.downloader-vpn · Media
13
11.jellyseerr · Media
14
12.tautulli · Media
15
13.nextcloud-mariadb · Productivity
16
14.docmost · Productivity
17
15.vaultwarden · Productivity
18
16.samba-nas · Productivity
19
17.duplicati · Productivity
20
18.cloudflare-tunnel · Network
21
19.adguard · Network
22
20.flame-dashboard · Tools
23
21.uptime-kuma · Tools
24
22.code-server · Tools
25
Part V — Cross-cutting patterns
23.External access · VPN isolation · Backups · Segmentation · Updates · Monitoring · Secrets
26
Appendix
A1.Image inventory
28
A2.Volume inventory
29
A3.Network inventory
30
A4.Glossary
31
A5.Technologies
32

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 · 32 GB RAM · Docker 29.x MEDIA mediastacks plex · sonarr · radarr bazarr · prowlarr · notifiarr downloader-vpn downloader via VPN namespace jellyseerr tautulli PROTONVPN via gluetun namespace port-forwarded · UI :8080 PRODUCTIVITY nextcloud docmost vaultwarden samba-nas duplicati nightly tarball vault → backup sidecar → duplicati → off-host independent failure domains, decoupled retention TOOLS & DASHBOARD portainer flame-dashboard uptime-kuma code-server AMP × 2 · game servers (standalone — not in a compose stack) 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 14 stacks · 27 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, 5454plex / sonarr / radarr / bazarr / prowlarr / notifiarr
downloader-vpnMediagluetun, downloader8080, 8090, 8090, 8888(LAN only · :8080)
jellyseerrMediajellyseerr5055jellyseerr.example.com
tautulliMediatautulli8181tautulli.example.com
nextcloud-mariadbProductivitydb, app8083cloud.example.com
docmostProductivitydocmost, db, redis3000docmost.example.com
vaultwardenProductivityvaultwarden, vaultwarden-backup8087vaultwarden.example.com
samba-nasProductivitysamba(LAN only · SMB)
duplicatiProductivityduplicati8200(LAN only · :8200)
cloudflare-tunnelNetworkcloudflared(meta — provides the tunnel)
adguardNetworkadguardadguard.example.com
flame-dashboardToolsflame5005(LAN only · :5005)
uptime-kumaToolsuptime-kuma3001status.example.com
code-serverToolscode-server8443code.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
plex / sonarr / radarr / bazarr / prowlarr / notifiarr
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
    # Plex needs host networking for DLNA / GDM discovery — direct-play to LAN clients with no NAT overhead.
    network_mode: host
    environment:
      - PUID=1000
      - PGID=1000
      - VERSION=docker
      # Claim token placeholder — replace with one from plex.tv/claim before first boot, or remove if already claimed via UI.
      - PLEX_CLAIM=Redacted
    volumes:
      - /srv/user/docker/plex/config:/config
      - /srv/user/Desktop/MoviesAndShows/Movies:/movies
      - /srv/user/Desktop/MoviesAndShows/Shows:/shows
      - /mnt/storage/BulkStore/Movies:/bulkstoremovies
      - /mnt/storage/BulkStore/Shows:/bulkstoreshows
      - /srv/user/Desktop/MoviesAndShows/Downloads:/downloads
      - /mnt/storage/BulkStore/Downloads:/bulkstoredownload
    restart: unless-stopped
    tmpfs:
      - "/transcode:size=8g"
    # https://plex.example.com

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    ports:
      - "8989:8989"
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/New_York
    volumes:
      - /srv/user/docker/sonarr/config:/config
      - /srv/user/Desktop/MoviesAndShows/Shows:/tv
      - /mnt/storage/BulkStore/Shows:/bulkstoreshows
      - /srv/user/Desktop/MoviesAndShows/Downloads:/downloads
      - /mnt/storage/BulkStore/Downloads:/bulkstoredownload
    restart: unless-stopped
    # https://sonarr.example.com

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    ports:
      - "7878:7878"
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/New_York
    volumes:
      - /srv/user/docker/radarr/config:/config
      - /srv/user/Desktop/MoviesAndShows/Movies:/movies
      - /mnt/storage/BulkStore/Movies:/bulkstoremovies
      - /srv/user/Desktop/MoviesAndShows/Downloads:/downloads
      - /mnt/storage/BulkStore/Downloads:/bulkstoredownload
    restart: unless-stopped
    # https://radarr.example.com

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    ports:
      - "9696:9696"
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/New_York
    volumes:
      - /srv/user/docker/prowlarr/config:/config
    restart: unless-stopped
    # https://prowlarr.example.com

  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    container_name: bazarr
    ports:
      - "6767:6767"
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/New_York
    volumes:
      - /srv/user/docker/bazarr/config:/config
      - /srv/user/Desktop/MoviesAndShows/Movies:/movies
      - /srv/user/Desktop/MoviesAndShows/Shows:/tv
      - /mnt/storage/BulkStore/Movies:/bulkstoremovies
      - /mnt/storage/BulkStore/Shows:/bulkstoreshows
    restart: unless-stopped
    # https://bazarr.example.com
    
  notifiarr:
    image: golift/notifiarr:latest
    container_name: notifiarr
    ports:
      - "5454:5454"
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/New_York
      - NOTIFIARR_API_KEY=Redacted
    volumes:
      - /srv/user/docker/notifiarr/config:/config
    restart: unless-stopped
    # https://notifiarr.example.com
🛡️

downloader-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
(LAN only · :8080)
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. Kill-switch behavior is structural — a property of the topology — not a runtime check that could fail.
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
🎟️

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
jellyseerr.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
tautulli.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
☁️

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:33-apache
Public hostname
cloud.example.com
Network mode
bridge
Exposed ports
8083
Services
2
Volumes
3
Services
ServiceImageContainerNetwork
dbmariadb:11nextcloud_dbbridge
appnextcloud:33-apachenextcloud_appbridge
Setup essentials
Imagesnextcloud:33-apache (pin to major version), mariadb:11
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 (29→30→31…). 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 the supported Nextcloud 33 major version.
Design decisions
Splitting the database into its own container and bridge network is defense in depth — even a Nextcloud RCE cannot directly attack the DB host 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
    environment:
      MYSQL_ROOT_PASSWORD: Redacted
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: Redacted
    volumes:
      - /mnt/storage/NAS/NextCloud/db:/var/lib/mysql
    # Dedicated bridge network — only nextcloud_app can reach the database.
    networks:
      - nextcloud-net

  app:
    image: nextcloud:33-apache
    container_name: nextcloud_app
    restart: unless-stopped
    depends_on:
      - db
    ports:
      - 8083:80
    environment:
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: Redacted
      # Resolves via Docker's internal DNS to the 'db' service on the bridge network.
      MYSQL_HOST: db
    volumes:
      - /mnt/storage/NAS/NextCloud/app:/var/www/html
      - /mnt/storage/NAS/NextCloud/data:/var/www/html/data
    networks:
      - nextcloud-net

networks:
  nextcloud-net:
    driver: bridge
📝

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.36.0-alpine
Public hostname
vaultwarden.example.com
Network mode
bridge
Exposed ports
8087
Services
2
Volumes
3
Services
ServiceImageContainerNetwork
vaultwardenvaultwarden/server:1.36.0-alpinevaultwardenbridge
vaultwarden-backupbruceforce/vaultwarden-backup:latestvaultwarden_backup_toolbridge
Setup essentials
Imagevaultwarden/server:1.36.0-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.36.0-alpine (smaller attack surface). External access gated by Cloudflare Access on top of Vaultwarden's own 2FA. Admin panel disabled (no ADMIN_TOKEN set).
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.36.0-alpine
    container_name: vaultwarden
    restart: unless-stopped
    ports:
      - "8087:80"
    volumes:
      - vaultwarden_data:/data
    environment:
      # Must match the public URL exactly — required for WebAuthn / passkey 2FA origin validation.
      - DOMAIN=https://vaultwarden.example.com

      # Upstream default is TRUE — explicitly disabled to prevent unauthorized account creation.
      - SIGNUPS_ALLOWED=false
      # Closes the secondary path: even existing users cannot invite new ones.
      - INVITATIONS_ALLOWED=false
      - SHOW_PASSWORD_HINT=false

      - WEBSOCKET_ENABLED=true
      - TZ=America/New_York

  vaultwarden-backup:
    image: bruceforce/vaultwarden-backup:latest
    container_name: vaultwarden_backup_tool
    restart: unless-stopped

    volumes:
      - vaultwarden_data:/data:ro
      - /mnt/storage/NAS/Vaultwarden_Backup_Cache:/backup

    environment:
      - TIMESTAMP=true
      - PUID=1000
      - PGID=1000
      - CRON_TIME=30 2 * * *
      - TZ=America/New_York
      - BACKUP_DIR=/backup
      - DOCKER_CONTAINER_NAME=vaultwarden
      - BACKUP_ON_STARTUP=true
      - DELETE_AFTER=30

volumes:
  vaultwarden_data:
📁

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
(LAN only · :8200)
Network mode
bridge
Exposed ports
8200
Services
1
Volumes
3
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. The READ-ONLY source mount is a structural guarantee that a backup misconfiguration cannot damage source data.
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

    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

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
🔥

flame-dashboard

Tools

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

Image(s)
pawelmalak/flame:latest
Public hostname
(LAN only · :5005)
Network mode
bridge
Exposed ports
5005
Services
1
Volumes
3
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
      # Read-only socket mount for container auto-discovery. Consider docker-socket-proxy for additional safety.
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /opt/flame/secrets/password:/run/secrets/password:ro
    ports:
      - "5005:5005"
    environment:
      - PASSWORD_FILE=Redacted
    restart: unless-stopped
📈

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
2
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
Self-hosted monitoring is the trust anchor for everything else — if the monitor is wrong, you don't know what else is wrong.
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
      # Read-only socket mount enables container-state monitoring beyond HTTP checks.
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - "3001:3001"
    environment:
      - TZ=America/New_York
💻

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

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

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
downloader-vpn · gluetun → qmcgaw/gluetun
downloader-vpn · downloader → lscr.io/linuxserver/sabnzbd:latest
jellyseerr · jellyseerr → fallenbagel/jellyseerr:latest
tautulli · tautulli → tautulli/tautulli
nextcloud-mariadb · db → mariadb:11
nextcloud-mariadb · app → nextcloud:33-apache
docmost · docmost → docmost/docmost:latest
docmost · db → postgres:16-alpine
docmost · redis → redis:7.2-alpine
vaultwarden · vaultwarden → vaultwarden/server:1.36.0-alpine
vaultwarden · vaultwarden-backup → bruceforce/vaultwarden-backup:latest
samba-nas · samba → dperson/samba
duplicati · duplicati → lscr.io/linuxserver/duplicati:latest
cloudflare-tunnel · cloudflared → cloudflare/cloudflared:latest
adguard · adguard → adguard/adguardhome:latest
flame-dashboard · flame → pawelmalak/flame:latest
uptime-kuma · uptime-kuma → louislam/uptime-kuma:latest
code-server · code-server → lscr.io/linuxserver/code-server:latest
standalone · portainer → portainer/portainer-ce:2.41.1
standalone · AMP_GameSrv01 → cubecoders/ampbase:debian
standalone · AMP_GameSrv02 → cubecoders/ampbase:debian

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
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
jellyseerr · /srv/user/docker/jellyseerr/config:/app/config
tautulli · /srv/user/docker/tautulli:/config
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
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
adguard · /opt/adguard/work:/opt/adguardhome/work
adguard · /opt/adguard/conf:/opt/adguardhome/conf
flame-dashboard · /opt/flame/data:/app/data
flame-dashboard · /var/run/docker.sock:/var/run/docker.sock:ro
flame-dashboard · /opt/flame/secrets/password:/run/secrets/password:ro
uptime-kuma · /opt/uptime-kuma/data:/app/data
uptime-kuma · /var/run/docker.sock:/var/run/docker.sock:ro # <--- ADD THIS LINE
code-server · /srv/user/docker/code-server/config:/config

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
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
nextcloud-mariadb · app → port 8083:80
docmost · docmost → port 3000:3000
vaultwarden · vaultwarden → port 8087:80
samba-nas · samba → host
duplicati · duplicati → port 8200:8200
adguard · adguard → host
flame-dashboard · flame → port 5005:5005
uptime-kuma · uptime-kuma → port 3001:3001
code-server · code-server → port 8443:8443

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