Docker setup
This guide runs zurg in Docker while making its FUSE mount visible on the Linux host. Docker supplies zurg, rclone and ffprobe and keeps the container running. The built-in setup command creates the config and the built-in doctor command checks the finished install.
The container setup flow was verified on 4 September 2026 with Docker Engine 29.1.1. Use the newest dated nightly release so the image contains the provider chooser described below.
Sponsor image:
ghcr.io/debridmediamanager/zurgis sponsors-only. See Patreon for access.
Linux host required: the host-visible mount below depends on Linux mount propagation. Docker Desktop runs containers inside a VM on macOS and Windows and cannot propagate this FUSE mount into Finder or Explorer. Use the macOS or Windows binary guide when the media server runs on either host.
One-line option
On a fresh Linux host, the optional bootstrap can install Docker when approved, configure persistent FUSE mount propagation, authenticate to the sponsor registry, write the Compose project, run the provider chooser, and verify the result:
curl -fsSL https://raw.githubusercontent.com/debridmediamanager/zurg-site/main/public/install-docker.sh | bash
Existing Compose files and configs are preserved. The sections below show every step for operators who prefer the manual route.
What the installer does in a container
The flags are deliberate:
docker compose run --rm zurg setup \
--no-service \
--skip-downloads \
--mount-path /zurg_mnt/zurg
setupasks which providers to configure, prompts only for their credentials and writesconfig.ymlwith mode0600.--no-serviceleaves auto-start to Docker'srestart: unless-stoppedpolicy instead of trying to install systemd inside Alpine.--skip-downloadsuses the rclone and ffprobe already installed in the image.--mount-pathrecords the subdirectory whose FUSE mount will propagate back to the host.
Running setup again is safe. It validates and updates the install but does not replace an existing config.yml.
1. Install Docker and unlock the image
Install Docker Engine with the Compose plugin on the Linux host. Check both commands before continuing:
docker version
docker compose version
Sign in to GitHub Container Registry with your GitHub username. When prompted for the password, paste a classic personal access token with both the read:packages and repo scopes. The image belongs to a private repository, and a token with read:packages alone can log in but still fail to pull:
docker login ghcr.io -u YOUR_GITHUB_USERNAME
Set ZURG_TAG to the newest dated sponsor release. Keeping it in .env gives you a known rollback target and makes upgrades explicit:
printf 'ZURG_TAG=YYYY.MM.DD.HHMM-nightly\n' > .env
Replace the placeholder with the actual release tag before continuing.
2. Prepare FUSE mount propagation
The FUSE mount must be created below a shared parent mount. Run this once before the first container starts:
sudo mkdir -p /zurg_mnt
sudo modprobe fuse
sudo mount --bind /zurg_mnt /zurg_mnt
sudo mount --make-rshared /zurg_mnt
Verify the device and propagation mode:
test -c /dev/fuse
findmnt -o TARGET,PROPAGATION /zurg_mnt
The second command should report shared under PROPAGATION.
3. Create the compose project
mkdir -p ~/zurg
cd ~/zurg
Create docker-compose.yml:
services:
zurg:
image: ghcr.io/debridmediamanager/zurg:${ZURG_TAG}
container_name: zurg
restart: unless-stopped
devices:
- /dev/fuse
cap_add:
- SYS_ADMIN
security_opt:
- apparmor:unconfined
ports:
- 9999:9999
volumes:
- ./:/config
- /zurg_mnt:/zurg_mnt:rshared
privileged: true is not required. The FUSE device, SYS_ADMIN capability and unconfined AppArmor profile are the narrower set used by the mount.
Do not bind /zurg_mnt/zurg directly. The FUSE mount must be a child of the rshared bind for its mount event to reach the host.
Containers that read the mount, Sonarr and Radarr above all, follow the same rule from the other side: bind the parent (/zurg_mnt) once, with rslave, and reach every folder through that one bind. Binding __magic__/__all__ and __magic__/tv into an *arr as two volumes turns every import into a copy of the whole file. See sabnzbd.md.
4. Run the built-in setup
From ~/zurg run:
docker compose run --rm zurg setup \
--no-service \
--skip-downloads \
--mount-path /zurg_mnt/zurg
Choose one or several providers in priority order. For example enter 2,4 for TorBox followed by Usenet. Setup prompts only for their credentials, creates config.yml in ~/zurg, enables rclone and creates /zurg_mnt/zurg. No credential is stored in the compose file or shell history.
For automation, repeat --provider and pass the provider-specific token-file or --nntp-* flags. Setup preserves an existing config on later runs.
Setup writes the accounts only on the run that creates config.yml. To add one to an install that already has a config, use the Dashboard's Add provider or edit the file, then restart. Each service has its own page with the credential, the config block and the limits worth knowing: Providers.
5. Start zurg
docker compose up -d
docker compose logs -f --tail=100 zurg
Leave the log view with Ctrl+C; the container keeps running. The Dashboard is at http://localhost:9999/config/ and edits the same config.yml stored beside the compose file.
A large library can take a while to load. Folders in the mount fail to list until it has. They show up empty or as Input/output error while the Dashboard and version.txt work as normal. That means zurg is still loading and not that the mount is broken.
6. Verify with doctor
Once the library is ready, run the same diagnostic tool used by a binary install:
docker compose exec zurg /app/zurg doctor --working-dir /config
doctor checks the config and its permissions, ffprobe, rclone, FUSE, the HTTP endpoint and the mount reported by zurg. WARN service zurg is not installed is expected in Docker because Compose owns auto-start. Warnings do not make the command fail; any FAIL does.
Confirm that the propagated mount is also visible outside the container:
cat /zurg_mnt/zurg/version.txt
ls -la /zurg_mnt/zurg/
7. Make the shared parent survive reboots
The self-bind from step 2 does not survive a reboot. Create a systemd oneshot unit on the host:
On the next reboot the host prepares the shared parent before Docker restores the zurg container.
Up versus ready
zurg answers two different questions over HTTP. Neither needs a password even when username is set.
The container's health check asks the first one. docker compose ps shows healthy as soon as zurg is running. A large library can keep loading for a long time after that. Ask /ready when you need the whole library. It prints ready once the load is done.
curl -fsS http://localhost:9999/ready
docker compose exec zurg /app/healthcheck.sh --ready && echo ready
A script can wait for it like this.
until curl -fsS http://localhost:9999/ready >/dev/null; do sleep 10; done
Why the health check does not wait for the library
Docker keeps one health status per container. The tools that read it take it to mean "is zurg alive". autoheal and Docker Swarm restart a container that turns unhealthy. depends_on with condition: service_healthy holds other services back until zurg is healthy.
Cold starts of 15 to 90 minutes have been reported on large libraries. A health check that waited for the library would hold every dependent service back for that long. An rclone container in front of zurg would leave its mount folder empty the whole time. An empty folder looks to a media server like a library that has been deleted. A load that outlasted the health check's retries would also be marked unhealthy. Anything that restarts unhealthy containers would then start the load over. It would never finish.
Using readiness with Compose
Keep a mount container on the default health check so its mount comes up as soon as zurg is running.
services:
rclone:
depends_on:
zurg:
condition: service_healthy
Wait on /ready in whatever needs the whole library. That could be a scan script or an import job.
You can make the zurg container itself report healthy only once the library has loaded. Override its health check in docker-compose.yml and set start_period longer than your slowest load. Docker does not count failures inside the start period. Every service that depends on zurg then waits for the library as well. Do not combine this with autoheal.
services:
zurg:
healthcheck:
test: ["CMD", "/app/healthcheck.sh", "--ready"]
interval: 30s
timeout: 10s
start_period: 3h
retries: 3
Managing and updating zurg
Run these from ~/zurg:
docker compose ps
docker compose logs -f --tail=100 zurg
docker compose restart zurg
docker compose stop zurg
docker compose start zurg
The compose project tracks ZURG_TAG from .env, which the installer sets to latest so a pull always brings the newest nightly. To upgrade, pull and recreate the container, then run doctor:
docker compose pull zurg
docker compose up -d zurg
docker compose exec zurg /app/zurg doctor --working-dir /config
zurg update is not the route here and refuses to run inside a container: the binary it writes lives in the container layer and is lost the moment the container is recreated, so the next up would silently be back on the old build. Pull the image instead. --force overrides the refusal for a container you intend to keep.
To roll back, set ZURG_TAG in .env to a specific nightly tag and run those three commands again. The bind-mounted config, library state, logs and cache do not move with the container.
docker compose down removes the container and network but leaves the bind-mounted files in ~/zurg and the mount parent at /zurg_mnt.
What lives where
Everything zurg writes resolves against /config, so the single ./:/config bind holds the install:
Pulling or recreating the image changes none of these files.
data/rclone-cache is the one that grows. The mount downloads every file it
reads in full and keeps it after playback ends, up to 256G by default, so
the bind directory needs room for the cache on top of everything else. The 72h
age limit does not bound it in practice — anything that reads the library, a
media server's nightly pass included, counts as touching the file.
du -sh ./data/rclone-cache
Cap it lower in config.yml when the host disk cannot spare that much:
rclone_extra_args:
- "--vfs-cache-max-size"
- "50G"
- "--vfs-cache-min-free-space"
- "20G"
Changing the host path behind /config renames the cache and strands the old
copy; zurg reclaims stranded trees at the next start and logs what it freed.
See config.md.
Environment seeding for unattended installs
The image still supports the older first-run path. If no config.yml exists, TOKEN or RD_TOKEN seeds the account and MOUNT_PATH enables the mount. Those variables are read only while the file is being created; the file wins on every later start.
Real-Debrid is the only account the running container seeds this way. TORBOX_TOKEN, ALLDEBRID_TOKEN, PREMIUMIZE_TOKEN, DEBRIDLINK_TOKEN, OFFCLOUD_TOKEN and NNTP_HOST are read by zurg setup, so they take effect in the one-off setup container above and do nothing sitting in the service's environment: block.
The interactive setup command is preferred for a person at a terminal because the token never enters the compose file. Environment seeding remains useful for automation that already has a secret store.
Upgrading an install that mounted /app
Older compose projects bind-mounted /app/config.yml, /app/data and other paths separately. The entrypoint still selects /app whenever it finds that layout, so pulling a newer image keeps using the old config and cache.
To adopt the single-folder layout, stop the container, copy the existing config.yml, data/ and other state directories into one host directory, then bind that directory at /config.
Troubleshooting
Start with the diagnostic and the current logs:
docker compose exec zurg /app/zurg doctor --working-dir /config
docker compose logs --tail=200 zurg
unknown command "setup" or unknown command "doctor"
The container predates the built-in installer. Use 2026.09.04.1449-nightly or a newer dated zurg image, then recreate it:
docker compose pull zurg
docker compose up -d zurg
Transport endpoint is not connected
The old FUSE mount is stale:
sudo fusermount -uz /zurg_mnt/zurg
docker compose restart zurg
FUSE device is unavailable
Load the module now and on later boots:
Then confirm the compose service still includes /dev/fuse, SYS_ADMIN and the AppArmor setting.
Mount is empty
Check each layer in order:
docker compose logs zurg 2>&1 | grep -i rclone
docker compose exec zurg ls -la /zurg_mnt/zurg
curl -fsS http://localhost:9999/http/version.txt
curl -fsS http://localhost:9999/ready
findmnt -o TARGET,PROPAGATION /zurg_mnt
The host path must be a shared bind mount and the compose volume must bind the parent as /zurg_mnt:/zurg_mnt:rshared.
If /ready fails with error 503 the library is still loading and the mount is fine. Its folders list as empty or show Input/output error until the load finishes. Wait for it instead of restarting zurg. A restart starts the load over.
Mount appears twice in mount output
That is normal. The same FUSE mount is visible in the container and host mount namespaces because propagation is working.