We’ve picked the crew and drawn the map. All that’s left is to actually cast off. This is where the whole fleet gets folded into a single compose.yml and — spoiler — it really is close to docker compose up -d easy.

The .env first

The Linuxserver.io images all speak the same three variables, and everything paths off two folders. Get these right and the rest is copy-paste.

COMPOSE_PROJECT_NAME=htpc

# > id
PUID=1026
PGID=100

# > curl https://ipapi.co/timezone
TZ=Europe/Brussels

# Media files (movies, series, downloads) — one root, see below
DATA_PATH=./htpc

# Container config. Do NOT put this in a Dropbox/GDrive-synced/SMB folder.
# !! This will corrupt the Radarr SQLite DB!!
CONFIG_PATH=./config

# no | on-failure | always | unless-stopped
RESTART_POLICY=unless-stopped

# For automatic updates with Watchtower
DOCKER_SOCKET=/var/run/docker.sock

# If you remember one port, make it Heimdall's:
HEIMDALL_PORT=9999
SONARR_PORT=8989
RADARR_PORT=7878
PROWLARR_PORT=9696
BAZARR_PORT=6767
QBITTORRENT_PORT=8080
FLARESOLVERR_PORT=8191
JELLYSEERR_PORT=5055
JELLYSTAT_PORT=3000

# Jellystat brings its own Postgres
JELLYSTAT_DB_USER=jellystat
JELLYSTAT_DB_PASSWORD=changeme
# openssl rand -hex 32
JELLYSTAT_JWT_SECRET=replace-with-a-long-random-string

# Recyclarr sync cron
RECYCLARR_CRON=0 4 * * *

PUID/PGID are your user’s ids (run id), so the containers write files you actually own instead of root. The full .env-example has the Watchtower notification block too (Slack/e-mail), left blank here.

The one thing you must not get wrong

Give every download-and-media container the same /data root:

volumes:
  - ${DATA_PATH}:/data

qBittorrent downloads into /data/downloads, Sonarr/Radarr import into /data/media/... — all on one filesystem, one mount. That’s what lets them hardlink and do atomic (instant) moves instead of a slow, space-doubling copy on every import.

The classic beginner mistake is mounting /downloads in the torrent client and /movies in Radarr as separate volumes. To the containers those are different filesystems, so every import becomes a full copy and seeding a file you’ve also imported costs you the space twice. One /data root and the problem disappears.

The compose file

Nothing exotic — the same shape repeated per service: the LSIO trinity, a config volume, the shared /data, a port, a restart policy, and a Watchtower label.

version: "3"

services:
  # Dashboard — the one URL you actually bookmark
  heimdall:
    image: linuxserver/heimdall
    container_name: htpc-heimdall
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/heimdall:/config
    ports:
      - ${HEIMDALL_PORT}:80
      - ${HEIMDALL_PORT_SSH}:443
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # TV series
  sonarr:
    image: linuxserver/sonarr
    container_name: htpc-sonarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/sonarr:/config
      - ${DATA_PATH}:/data
    ports:
      - ${SONARR_PORT}:8989
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Movies — same recipe, different port
  radarr:
    image: linuxserver/radarr
    container_name: htpc-radarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/radarr:/config
      - ${DATA_PATH}:/data
    ports:
      - ${RADARR_PORT}:7878
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Indexer aggregator (syncs indexers into Sonarr/Radarr)
  prowlarr:
    image: lscr.io/linuxserver/prowlarr:develop
    container_name: htpc-prowlarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/prowlarr:/config
    ports:
      - ${PROWLARR_PORT}:9696
    restart: unless-stopped
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Subtitles
  bazarr:
    image: linuxserver/bazarr
    container_name: htpc-bazarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ${CONFIG_PATH}/bazarr:/config
      - ${DATA_PATH}:/data
    ports:
      - ${BAZARR_PORT}:6767
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Torrent client — same /data root → hardlinks kept
  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent
    container_name: htpc-qbittorrent
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - WEBUI_PORT=${QBITTORRENT_PORT}
    volumes:
      - ${CONFIG_PATH}/qbittorrent:/config
      - ${DATA_PATH}:/data
    ports:
      - ${QBITTORRENT_PORT}:${QBITTORRENT_PORT}
      - 6881:6881
      - 6881:6881/udp
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Solves Cloudflare challenges for Prowlarr (proxy: http://flaresolverr:8191)
  flaresolverr:
    image: ghcr.io/flaresolverr/flaresolverr
    container_name: htpc-flaresolverr
    environment:
      - LOG_LEVEL=info
      - TZ=${TZ}
    ports:
      - ${FLARESOLVERR_PORT}:8191
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Requests — point it at wherever Jellyfin lives
  jellyseerr:
    image: fallenbagel/jellyseerr
    container_name: htpc-jellyseerr
    environment:
      - TZ=${TZ}
      - LOG_LEVEL=info
    volumes:
      - ${CONFIG_PATH}/jellyseerr:/app/config
    ports:
      - ${JELLYSEERR_PORT}:5055
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Jellyfin watch analytics — needs its own Postgres
  jellystat-db:
    image: postgres:16
    container_name: htpc-jellystat-db
    environment:
      - POSTGRES_USER=${JELLYSTAT_DB_USER}
      - POSTGRES_PASSWORD=${JELLYSTAT_DB_PASSWORD}
    volumes:
      - ${CONFIG_PATH}/jellystat-db:/var/lib/postgresql/data
    restart: ${RESTART_POLICY}

  jellystat:
    image: cyfershepard/jellystat
    container_name: htpc-jellystat
    environment:
      - TZ=${TZ}
      - POSTGRES_USER=${JELLYSTAT_DB_USER}
      - POSTGRES_PASSWORD=${JELLYSTAT_DB_PASSWORD}
      - POSTGRES_IP=jellystat-db
      - POSTGRES_PORT=5432
      - JWT_SECRET=${JELLYSTAT_JWT_SECRET}
    volumes:
      - ${CONFIG_PATH}/jellystat/backup:/app/backend/backup-data
    ports:
      - ${JELLYSTAT_PORT}:3000
    depends_on:
      - jellystat-db
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Syncs TRaSH-guides quality profiles into Sonarr/Radarr on a cron
  recyclarr:
    image: ghcr.io/recyclarr/recyclarr
    container_name: htpc-recyclarr
    user: ${PUID}:${PGID}
    environment:
      - TZ=${TZ}
      - CRON_SCHEDULE=${RECYCLARR_CRON}
    volumes:
      - ${CONFIG_PATH}/recyclarr:/config
    restart: ${RESTART_POLICY}
    labels:
      - 'com.centurylinklabs.watchtower.enable=true'

  # Auto-updates every labelled container above
  watchtower:
    image: containrrr/watchtower
    container_name: watchtower
    restart: ${RESTART_POLICY}
    environment:
      - TZ=${TZ}
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_LABEL_ENABLE=${WATCHTOWER_LABEL_UPDATE}
      - 'WATCHTOWER_SCHEDULE=${WATCHTOWER_SCHEDULE}'
    volumes:
      - ${DOCKER_SOCKET}:/var/run/docker.sock

I trimmed the expose: blocks and Watchtower’s full notification env for readability — the unabridged file is in the repo.

The bits worth pointing at

A few services earn their own paragraph — most already have a full write-up elsewhere in the series:

  • qBittorrent replaced Transmission — here’s why. Note 6881 is published verbatim (not via .env) it is the BitTorrent listen port.
  • Jellyseerr and Jellystat both point at a Jellyfin that isn’t in this file — mine runs in a separate LXC. Requests > Sonarr/Radarr is why Ombi got retired.
  • Recyclarr runs user: ${PUID}:${PGID} directly (no LSIO wrapper) and syncs the TRaSH guides on a cron.

Notice there’s no Jellyfin, no Prowlarr indexer secrets, no API keys in here. Media serving lives elsewhere and every credential is entered in each app’s own UI on first boot. The compose only wires up the plumbing.

Watchtower: the self-updating fleet

Every service carries the same label:

labels:
  - 'com.centurylinklabs.watchtower.enable=true'

With WATCHTOWER_LABEL_ENABLE=true, Watchtower only touches containers wearing that label — so a stray container on the same host won’t get surprise-upgraded. It pulls fresh images on the schedule, recreates the container with the same env/volumes, and WATCHTOWER_CLEANUP=true bins the old image. The full .env wires up Slack/e-mail so you get a ping when it does.

Watchtower is the lazy option, not the safe one — it can pull a breaking :latest. If you’d rather update on your own terms, drop it and run docker compose pull && docker compose up -d by hand. More on keeping the fleet in check in Container Management.

Casting off

cp .env-example .env    # then edit PUID/PGID/TZ/paths
docker compose up -d

That’s genuinely it. docker compose logs -f sonarr if something sulks, docker compose down to stop the lot. Then open Heimdall on port 9999 and start configuring — which, as promised way back at the start, is where the real time goes.

Conclusion

Twelve containers, two files, one command. The compose file is deliberately boring: every service is the same handful of lines, all the variety lives in .env, and the only genuinely load-bearing decision is that shared /data root. Everything clever — quality profiles, indexers, requests — happens after up -d, in each app’s own UI.

Fair winds, and may your terabytes fill responsibly. 🏴‍☠️