Media stack pod: Radarr, Sonarr, Lidarr, Prowlarr, NZBGet, Bazarr

Six media apps in one rootless pod that talk over localhost and share one /srv/media folder.

  • Pod with several containers
  • Rootless: systemctl --user
  • Tested with podman 5.0 and newer

Last updated

Updated : added Lidarr and a health check for every app.

In short: One media.pod publishes every web UI; each app is a .container with Pod=media.pod and its own config volume. Inside the pod the apps reach each other on localhost (Radarr finds NZBGet at localhost:6789), and all of them mount the same /srv/media as /data, so finished downloads are hardlinked or moved instantly instead of copied. Each app has a health check that restarts it when it hangs, and systemctl --user start media-pod.service starts the pod and all six containers.

The quadlet files

Every quadlet file below passes podman's quadlet generator and systemd-analyze verify on podman 5.0 and the current release. Personal values are placeholders: replace example.com, /srv/... paths and the time zone with your own.

media.pod

Save as ~/.config/containers/systemd/media.pod

[Unit]
Description=Media automation pod

[Pod]
PodName=media
# Containers in a pod share one network namespace, so ports are published here.
# NZBGet
PublishPort=6789:6789
# Radarr
PublishPort=7878:7878
# Sonarr
PublishPort=8989:8989
# Prowlarr
PublishPort=9696:9696
# Bazarr
PublishPort=6767:6767
# Lidarr
PublishPort=8686:8686

nzbget.container

Save as ~/.config/containers/systemd/nzbget.container

[Unit]
Description=NZBGet Usenet downloader

[Container]
Image=lscr.io/linuxserver/nzbget:latest
ContainerName=nzbget
Pod=media.pod
AutoUpdate=registry
Environment=TZ=Etc/UTC
Environment=NZBGET_USER=nzbget
# Web UI password from a podman secret instead of the image's default.
Secret=nzbget-pass,type=env,target=NZBGET_PASS
Volume=nzbget-config.volume:/config
Volume=/srv/media:/data
# Restart the app when it hangs, not only when it crashes.
HealthCmd=nc -z 127.0.0.1 6789
HealthInterval=1m
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=2m
HealthOnFailure=kill

[Service]
Restart=always
# The first start pulls the image, which can take longer than the default 90 seconds.
TimeoutStartSec=900

[Install]
WantedBy=default.target

nzbget-config.volume

Save as ~/.config/containers/systemd/nzbget-config.volume

[Volume]
VolumeName=nzbget-config

radarr.container

Save as ~/.config/containers/systemd/radarr.container

[Unit]
Description=Radarr movie manager

[Container]
Image=lscr.io/linuxserver/radarr:latest
ContainerName=radarr
Pod=media.pod
AutoUpdate=registry
Environment=TZ=Etc/UTC
Volume=radarr-config.volume:/config
Volume=/srv/media:/data
# Restart the app when it hangs, not only when it crashes.
HealthCmd=curl -fsS -o /dev/null http://127.0.0.1:7878/ping
HealthInterval=1m
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=3m
HealthOnFailure=kill

[Service]
Restart=always
# The first start pulls the image, which can take longer than the default 90 seconds.
TimeoutStartSec=900

[Install]
WantedBy=default.target

radarr-config.volume

Save as ~/.config/containers/systemd/radarr-config.volume

[Volume]
VolumeName=radarr-config

sonarr.container

Save as ~/.config/containers/systemd/sonarr.container

[Unit]
Description=Sonarr TV series manager

[Container]
Image=lscr.io/linuxserver/sonarr:latest
ContainerName=sonarr
Pod=media.pod
AutoUpdate=registry
Environment=TZ=Etc/UTC
Volume=sonarr-config.volume:/config
Volume=/srv/media:/data
# Restart the app when it hangs, not only when it crashes.
HealthCmd=curl -fsS -o /dev/null http://127.0.0.1:8989/ping
HealthInterval=1m
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=3m
HealthOnFailure=kill

[Service]
Restart=always
# The first start pulls the image, which can take longer than the default 90 seconds.
TimeoutStartSec=900

[Install]
WantedBy=default.target

sonarr-config.volume

Save as ~/.config/containers/systemd/sonarr-config.volume

[Volume]
VolumeName=sonarr-config

lidarr.container

Save as ~/.config/containers/systemd/lidarr.container

[Unit]
Description=Lidarr music manager

[Container]
Image=lscr.io/linuxserver/lidarr:latest
ContainerName=lidarr
Pod=media.pod
AutoUpdate=registry
Environment=TZ=Etc/UTC
Volume=lidarr-config.volume:/config
Volume=/srv/media:/data
# Restart the app when it hangs, not only when it crashes.
HealthCmd=curl -fsS -o /dev/null http://127.0.0.1:8686/ping
HealthInterval=1m
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=3m
HealthOnFailure=kill

[Service]
Restart=always
# The first start pulls the image, which can take longer than the default 90 seconds.
TimeoutStartSec=900

[Install]
WantedBy=default.target

lidarr-config.volume

Save as ~/.config/containers/systemd/lidarr-config.volume

[Volume]
VolumeName=lidarr-config

prowlarr.container

Save as ~/.config/containers/systemd/prowlarr.container

[Unit]
Description=Prowlarr indexer manager

[Container]
Image=lscr.io/linuxserver/prowlarr:latest
ContainerName=prowlarr
Pod=media.pod
AutoUpdate=registry
Environment=TZ=Etc/UTC
Volume=prowlarr-config.volume:/config
# Restart the app when it hangs, not only when it crashes.
HealthCmd=curl -fsS -o /dev/null http://127.0.0.1:9696/ping
HealthInterval=1m
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=2m
HealthOnFailure=kill

[Service]
Restart=always
# The first start pulls the image, which can take longer than the default 90 seconds.
TimeoutStartSec=900

[Install]
WantedBy=default.target

prowlarr-config.volume

Save as ~/.config/containers/systemd/prowlarr-config.volume

[Volume]
VolumeName=prowlarr-config

bazarr.container

Save as ~/.config/containers/systemd/bazarr.container

[Unit]
Description=Bazarr subtitle manager

[Container]
Image=lscr.io/linuxserver/bazarr:latest
ContainerName=bazarr
Pod=media.pod
AutoUpdate=registry
Environment=TZ=Etc/UTC
Volume=bazarr-config.volume:/config
Volume=/srv/media:/data
# Restart the app when it hangs, not only when it crashes.
HealthCmd=curl -fsS -o /dev/null http://127.0.0.1:6767/api/system/ping
HealthInterval=1m
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=3m
HealthOnFailure=kill

[Service]
Restart=always
# The first start pulls the image, which can take longer than the default 90 seconds.
TimeoutStartSec=900

[Install]
WantedBy=default.target

bazarr-config.volume

Save as ~/.config/containers/systemd/bazarr-config.volume

[Volume]
VolumeName=bazarr-config

Why is it built this way?

Why a pod. The six apps call each other constantly: Prowlarr pushes indexers to Radarr, Sonarr and Lidarr, and those send downloads to NZBGet. In one .pod they share a network namespace and talk over localhost, the pod publishes every web UI in one place, and media-pod.service starts and stops the whole stack. The generated pod service pulls in every container that has Pod=media.pod, so you never start the containers one by one.

Why no .network file. A pod already has its own network namespace, and the containers in it never need to be found by name. A .network file is only worth adding when containers outside the pod must reach it by name, for example a reverse proxy container on the same network.

One config volume per app. Each app keeps its database and settings in its own named volume (radarr-config.volume and so on). Named volumes survive updates and recreation, podman creates them with the right ownership, and you can back up or reset one app without touching the others.

One shared media folder. Every app that touches files mounts the same host folder: Volume=/srv/media:/data, with downloads, movies and tv inside it. Because it is one mount, a finished download can be hardlinked into the library instantly. Quadlet also adds RequiresMountsFor=/srv/media to each generated service, which matters when the folder is a NAS mount.

latest with auto-update and rollback. The linuxserver images publish only latest and exact build tags, no major-version tags. latest is their stable channel, so these files use it with AutoUpdate=registry: podman auto-update restarts changed containers and rolls back an image that fails to start. Pin an exact version-… tag instead if you prefer to update by hand.

Health checks. A crashed app is restarted by Restart=always, but a hung one keeps running. Each container calls its app's own ping URL once a minute on 127.0.0.1; after three failures in a row, HealthOnFailure=kill stops it and systemd starts it again. HealthStartPeriod= gives slow first starts and database migrations time before failures count.

Rootless. All ports are above 1024 and nothing needs host access, so the stack runs under a normal user. TimeoutStartSec=900 gives the first start time to pull the images; the systemd default of 90 seconds is often too short.

How do I start it?

Run these as your normal user, not as root. Quadlet reads ~/.config/containers/systemd/ for user services.

mkdir -p ~/.config/containers/systemd
# one media folder, owned by the containers' user (UID 911 in linuxserver images)
sudo mkdir -p /srv/media && sudo chown "$USER": /srv/media
mkdir -p /srv/media/downloads /srv/media/movies /srv/media/tv /srv/media/music
podman unshare chown -R 911:911 /srv/media
# NZBGet web UI password
openssl rand -hex 16 | tr -d '\n' | podman secret create nzbget-pass -
systemctl --user daemon-reload
systemctl --user start media-pod.service
systemctl --user enable --now podman-auto-update.timer
loginctl enable-linger "$USER"

Check the result with systemctl --user status and the logs with journalctl --user -u followed by the service name. To turn a docker-compose file of your own into files like these, use the compose to quadlet converter.

Common mistakes

Radarr or Sonarr: import failed, path does not exist or is not accessible

The download client and the manager must see finished downloads under the same path. Here every app mounts /srv/media as /data, so NZBGet's /data/downloads is the same folder in Radarr. Set NZBGet's MainDir to /data/downloads and the root folders to /data/movies and /data/tv.

Imports are slow copies instead of instant moves or hardlinks

Hardlinks and instant moves only work inside one filesystem and one mount. Separate mounts such as /downloads and /movies look like two filesystems to the app, even on the same disk. Mount one parent folder (/srv/media:/data) in every container.

Permission denied when an app writes to /data

In rootless podman, UID 911 inside the container is not your user on the host. Run podman unshare chown -R 911:911 /srv/media once. On SELinux systems (Fedora, openSUSE Tumbleweed) also add :z to the mount (Volume=/srv/media:/data:z), lowercase because several containers share it.

Apps cannot reach each other at radarr:7878 or nzbget:6789

Containers in one pod share a network namespace, so they are all localhost to each other. Use localhost:7878 for Radarr in Prowlarr, and localhost:6789 for NZBGet in Radarr and Sonarr.

Podman refuses a PublishPort= line in one of the .container files

A container in a pod cannot publish its own ports; the pod owns the network. Move every PublishPort= to media.pod and restart media-pod.service.

Frequently asked questions

Should Radarr, Sonarr and Prowlarr share a pod or a network?

Both work. A pod gives one network namespace (everything on localhost), one place for ports and one service that starts and stops the whole stack. A .network file keeps containers separate and lets them find each other by name, which suits apps you want to restart or expose independently.

Why mount one /data folder instead of separate downloads and movies folders?

Because hardlinks and atomic moves need both paths on one mount. With a single /srv/media:/data mount, Radarr can hardlink a finished download into /data/movies in milliseconds without using extra disk space.

How do the containers in this pod get updated?

Each .container has AutoUpdate=registry. With podman-auto-update.timer enabled, podman pulls newer latest images daily, restarts the changed services, and rolls back to the old image if a new one fails to start.

How do I add another app to the media pod?

Create a new .container with Pod=media.pod and its own .volume, add its port as a PublishPort= line in media.pod, then run systemctl --user daemon-reload and systemctl --user restart media-pod.service. That restart recreates the pod and stops every app for a moment; to avoid it on a running stack, run the new app standalone as in the Lidarr example.

What if /srv/media is on a NAS?

Mount the share on the host (fstab or a systemd .mount unit) and keep Volume=/srv/media:/data. Quadlet adds RequiresMountsFor=/srv/media to each generated service, so systemd waits for the mount before starting the container.