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.
See where this fits on the homelab connections map, with every port it uses.
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.