zurg Configuration Documentation
This document details all available configuration options for zurg. Configure these options in your config.yml file.
Tip: Most settings can be configured via the Dashboard at
http://localhost:9999/config/. The web interface provides an easier way to view and modify settings without editing the config file directly.
Migrating from a pre-providers config
If your config was written before the multi-account support landed, it still starts: the top-level token:, download_tokens: and strm_link_token: keys are read as the one thing they could ever have described — a single Real-Debrid account — and startup warns that they are deprecated. Moving them into a providers entry is the only change your config file needs, whenever you get to it: no other key was renamed, retyped or removed.
It is not, however, the only thing that changes. A handful of directives keep their name and their value and decide something slightly different than they used to, because the code reading them moved. Those are collected under
The one change to make
A single Real-Debrid account used to be three top-level keys. All three now live inside one providers entry.
Before:
zurg: v1
token: YOUR_RD_API_TOKEN
download_tokens:
- ANOTHER_RD_API_TOKEN
strm_link_token: RD_TOKEN_FOR_STRM
After:
zurg: v1
providers:
- type: realdebrid
token: YOUR_RD_API_TOKEN
download_tokens:
- ANOTHER_RD_API_TOKEN
strm_link_token: RD_TOKEN_FOR_STRM
Delete the old keys once they are moved. Left beside a providers block that already configures Real-Debrid they are ignored, and startup warns about them, so the two can never silently disagree about which token is live. Nothing else in your file needs touching — every other key kept its name and meaning, and a config whose only credential was token: needs only this edit.
You are not forced to make it. An unmigrated config still starts: the three keys are read as one Real-Debrid account named realdebrid, and startup names the keys it read and what to write instead. They are deprecated, not supported — new account features are reachable only from a providers entry.
The TOKEN / RD_TOKEN environment variables still work and still auto-create a config on first run.
What became possible, that you may want
None of this is required. It is what the providers block bought:
Two smaller changes worth knowing about:
cdn_host_preferencestill works and still means Real-Debrid. It is now an alias forrd_cdn_host_preference, which wins if both are set.- Plex credentials from the Dashboard's auth flow are written back into
config.ymlasplex_server_urlandplex_token. They used to live indata/plex.json.
Migrating by hand vs. by dashboard
The Dashboard can finish it for you. An unmigrated config loads, so the providers editor is reachable, and it lists the Real-Debrid account your top-level keys were read as; saving any account moves those keys into the providers block on disk. Editing the file by hand and restarting does the same thing and is the only option if the config does not load at all.
One thing the Dashboard no longer accepts: POST /config used to take token, download_tokens and strm_link_token as top-level keys, and now answers 400 Unknown config option. Credentials are edited per account instead. Anything scripting those three needs updating.
What else changes when you upgrade
Every directive below keeps its name, type and value. What changed is the code that reads it, so the same config can produce a different result. Nothing here needs an edit — this is what to expect, and what to change only if you disagree with it.
Filters now see inside archives. any_file_inside_regex, any_file_inside_contains, any_file_inside_size_gte/_lte, their not_ forms, has_episodes and is_music used to be evaluated against the archive itself: one entry named Show.part01.rar, sized like the volume. Once zurg has listed an archive, they are evaluated against its contents instead — the real filenames, their real sizes, and deliberately not the archive's own size, which dwarfed everything in it. A season pack that read as one enormous file now matches has_episodes; any_file_inside_regex: /\.mkv$/i starts matching where it used to fail, and any_file_inside_not_regex stops matching where it used to pass. Separately, a release whose every file is .rar/.r00/.par2 reaches the filters at all now, where before it went straight to __unplayable__.
This arrives release by release rather than all at once: it applies only to archives zurg has already listed, and listings written by an older parser are discarded, so a release moves the first time something opens it. If a directory of yours depends on the old reading, express it against the archive name with regex/not_regex, which still match the release name.
Tag filters no longer catch releases that never probed. zurg_* tags come from ffprobe. When nothing could be probed, every tier table bottomed out and the release was still stamped zurg_sdr, zurg_very_low_video_bitrate, zurg_very_low_audio_bitrate and zurg_0to20mins_duration — describing a file nobody had measured. Those are no longer emitted. A filter naming one of those four changes meaning for un-probeable releases: has_tag/tags_match_all/tags_match_any stop catching them, and — the direction worth checking — not_has_tag, tags_missing_all and tags_missing_any start catching them. Filters naming any other tier are unaffected.
added_within_hours / added_within_days / added_after / added_before read an older timestamp for duplicates. When one release is present more than once, the entry now reports the earliest add time rather than the first one seen, so a second copy of an old release no longer makes it look newly added. A recent-style directory can drop a release it previously kept.
The mount caches directory listings for 12 hours. check_for_changes_every_secs used to set rclone's --dir-cache-time, so listings expired every 15 seconds by default. They now hold for 12h and zurg forgets the affected entries explicitly whenever the library changes, which keeps media-server scans warm. To go back, override it directly — rclone_extra_args wins over any built-in flag:
rclone_extra_args: ["--dir-cache-time", "15s"]
A __<account>__ directory appears at the mount root. Every configured account gets one, single-account configs included, created from the providers block rather than from directories. It holds everything that account holds, ignores your directory filters, and pins reads to that account. It is not something directories can suppress.
Two releases that shared a name are now two entries. They used to merge into one, silently folding one release's files into the other's. The one that does not keep the bare name is suffixed with six characters of its info hash — Movie.2024 {a1b2c3}. That suffixed string is what regex, not_regex, contains and not_contains match against, so an anchored pattern will miss it. Separately, entries disambiguated by a duplicate basename are renamed once on upgrade, from an account-minted file id to a path-derived tag.
.strm files change shape. With serve_strm_files, every .strm the mount serves changes content and reported size — the URL now addresses the library entry rather than one account's link, so it fails over and survives repairs. A media server sees every .strm change. With save_strm_files, the files already written are rewritten in place under the same names the first time zurg starts, and a media server picks up the new URL at its next scan. The new form is signed with a secret at data/strm_secret; if that cannot be written zurg logs a warning and starts anyway, and the links stop resolving after the next restart — worth not ignoring.
load_dumped_torrents will not load a pre-upgrade dump/. Cached torrents now carry the account they came from, and ones without that stamp are skipped. The main cache re-imports from the account on the next refresh; dump/ has no account behind it, so it comes up empty with a warning in the log. Re-export from a running instance if you need it.
Repair takes a different route when restrict_repair_to_cached and only_full_torrent_repair are false. Both keys are unchanged when set to true. With them off — the default — the per-file and batched strategies were replaced by a single re-add naming the broken files. Same intent, different requests against the account.
Smaller ones. ignore_renames now also decides the name of the on-disk .zurgtorrent (self-migrating on load). retries_until_failed now bounds Real-Debrid fair-usage retries, which previously retried forever. auto_analyze_new_torrents: false and a missing ffprobe_binary no longer suppress cache cleanup, which used to be skipped along with the analysis. A size filter that excludes an archive's first volume now hides that archive entirely rather than falling back to a later volume. Unauthenticated static assets widened from /static/css/* to /static/*; no previously protected route became public.
Windows Configuration
When running zurg on Windows, paths must be formatted differently:
# Mount path - use drive letter with escaped backslash or forward slash
mount_path: "P:\\" # Drive letter (escaped backslash)
mount_path: "P:/" # Drive letter (forward slash also works)
# Binary paths - use forward slashes for paths to executables
ffprobe_binary: "Q:/zurg/bin/ffprobe.exe"
rclone_binary: "Q:/zurg/bin/rclone.exe"
# Alternative: use double forward slashes (also valid)
ffprobe_binary: "Q://zurg//bin//ffprobe.exe"
rclone_binary: "Q://zurg//bin//rclone.exe"
Key points for Windows:
- Use quotes around all paths
- For
mount_path: use a drive letter likeP:\(escaped asP:\\in YAML) orP:/ - For binary paths: forward slashes
/work and avoid escaping issues - Ensure
.exeextension is included for executables
1. Essentials (Setup First)
These are the non-negotiables to get the server up and running.
zurg: v1
providers:
- type: realdebrid
token: YOUR_RD_API_TOKEN
mount_path: "/mnt/zurg"
base_url: "http://192.168.0.123:9999"
Accounts
Each entry in providers is one account. Any number can run side by side — Real-Debrid, TorBox, AllDebrid, Premiumize, Debrid-Link and Offcloud together, or several accounts on the same service.
providers:
- type: realdebrid
token: YOUR_RD_API_TOKEN
strm_link_token: YOUR_RD_API_TOKEN_FOR_STRM
download_tokens:
- ANOTHER_RD_API_TOKEN
- ANOTHER_RD_API_TOKEN_2
- type: torbox
token: YOUR_TORBOX_API_KEY
watchlist: true
- type: realdebrid
name: rd-backup
token: YET_ANOTHER_RD_API_TOKEN
disabled: true
TorBox's download_tokens setting does not provide the cross-account playback
fallback that RD and AD locked links allow. Its resolver changes the API key
while retaining the torrent/file IDs from the existing source. Separate TorBox
provider entries keep each account's IDs and catalog independent. For a local
library, put the portable manifests in each entry's own source directory.
TorBox seeding
tb_seed_torrents: never
Leave it at never unless you need it. It is there for private trackers that
count your ratio. A release grabbed through the
qbittorrent.torrent file is added with this setting, so with always (or
auto on an account set to seed) TorBox uploads it back to the tracker's swarm.
Importing it into Sonarr or Radarr does not stop that: a finished download the
client removes stays on the account.
A seeding torrent holds one of your plan's active slots for as long as it
seeds. TorBox counts a torrent that is seeding the same as one that is
downloading. Its plans allow 3, 5 or 10 at once (Essential, Standard, Pro) and
seed for up to 24 hours, 14 days or 30 days. Once every slot is held by
seeding torrents, new grabs and repairs on that account stall until a slot frees,
either when a torrent's seeding time runs out or when you delete it on TorBox.
Seeding also counts as upload against TorBox's fair-use limits. With never
none of this happens, which is why it is the default.
auto sends TorBox no seeding preference at all, so whatever your account's
seeding setting says applies. On an account where nobody has changed it, that
is TorBox's own Auto. always and never override the account setting for the
torrents zurg adds and leave everything else on the account alone.
Usenet accounts
The nzb type is Usenet rather than a debrid service: .nzb files dropped into the nzbs/ directory become torrents in the library, and reads are satisfied by fetching articles from your news server and decoding them on demand. Only one enabled nzb entry is supported, since both would scan the same directory — extra news servers go under that entry's nntp.servers instead, where they serve the same library rather than a second copy of it.
providers:
- type: nzb
nntp:
host: news.example.com
port: 563
tls: true
username: YOUR_USENET_USERNAME
password: YOUR_USENET_PASSWORD
connections: 8
cache_size_mb: 512
More than one news server
Retention is per-provider: an article one server has aged out is very often still on another. Without a second account the only way to recover a dead article is PAR2, and that costs a read of the entire release — so a second server is the difference between fetching one article and re-reading everything.
The keys above describe the first account. Anything under servers is a further account, asked only when the ones before it did not have the article. An existing single-server config keeps working unchanged.
providers:
- type: nzb
nntp:
host: unlimited.example.com # the first account
tls: true
username: USERNAME
password: PASSWORD
connections: 30
servers:
- host: second-unlimited.example.com
tls: true
username: USERNAME
password: PASSWORD
connections: 20
backbone: usenetexpress
- host: block.example.com # only for what the others lack
tls: true
username: USERNAME
password: PASSWORD
connections: 10
backup: true
backbone: omicron
The census that decides whether a release needs repairing asks every account before calling an article missing, so a release one provider has holes in does not trigger a PAR2 rebuild when another provider can still serve it.
A top-level key governs what repairs leave behind:
And two govern what a parsed NZB costs in memory:
The trade is worth stating plainly, because it is not the usual memory-for-speed one. A listing — a library refresh, a fingerprint check, an *arr asking whether a job has settled — never touches an article under any mode: a file's name, its size and its article count are answered from the entry itself. What the lazy modes cost is the first read of a release nothing has touched for nzb_segments_idle_secs, and what they save is the heap the article lists occupy the rest of the time. Measured on a 60-file, 30,000-segment release (Apple M3 Pro):
Two thirds of the mmap reload is the checksum over the index it has just mapped — the map itself is 0.08 ms — and it is worth that: without it a single bad byte inside an arena is served as a message id nobody posted, which the news server answers as a dead article and every layer above reads as damage to the release.
And on a real library — 2,636 NZBs, 27.6 million segments, the same binary run three times over the same corpus, idle after a scan:
Warm opens, streaming throughput and startup time were indistinguishable. mmap is the default wherever there is a real mapping because it wins on every axis but disk — a cold open included, since mapping a written arena is a syscall where parsing the NZB again is megabytes of XML. It costs about 55 bytes of disk per article and one extra write per release at scan time; the index is rewritten whenever the NZB changes and removed when the NZB goes. Choose reparse on a host where data/ must not grow. On Windows there is no mapping — the index would be read into the heap, which is resident memory plus a reload — so the default there stays resident and reparse is the lazy mode worth choosing.
2. Media Server Integration
Connect zurg to your media server so library updates, metadata matching, and watchlist monitoring work automatically.
Premiumize, Debrid-Link and Offcloud
These backends support library browsing, streamed and redirected reads, STRM,
magnet adds, .torrent uploads and deletion. Each account also appears in its
own __<name>__ directory. Add accounts through the dashboard or zurg setup;
unattended setup accepts --premiumize-token-file, --debridlink-token-file
and --offcloud-token-file, or PREMIUMIZE_TOKEN, DEBRIDLINK_TOKEN and
OFFCLOUD_TOKEN.
Premiumize mounts completed transfers and cloud files retained after the transfer
list is cleared. Nested folders keep their paths. Hashes supplied through zurg
are saved under data/premiumize/<account>/sources.json so matching across
accounts survives a restart or clearing completed transfers. Imported cloud
content without a recorded hash still streams, but cannot be repaired by hash or
matched to another account by hash. Deleting a Premiumize transfer through zurg
also deletes the files owned by that transfer. Cloud storage and fair-use quotas
remain the account's own limits.
Debrid-Link lists the seedbox with pagination and resolves each file by its ID. Its retired cache endpoint is not used; a cached-only add can only establish a hit from the add response. Daily add and traffic quota failures are reported as account limits. The backend pauses after a flood refusal.
Offcloud resolves file handles through the cloud explore endpoint. File sizes are checked against the delivery server and cached torrent metadata supplies folder paths where it can be matched unambiguously. An ambiguous file listing is reported as an error. Malformed magnets are rejected before submission.
Delivery URLs are refreshed when their cache expires or a read invalidates them. Keep generated URLs and STRM access private; provider quotas belong to the account serving the bytes. These three backends use one credential per account entry and do not rotate through another account's file IDs.
Plex Integration
Note: Plex is best configured through the Dashboard (web UI) authentication flow; it writes
plex_server_urlandplex_tokenback intoconfig.yml. The keys above remain supported for manual configuration.
plex_server_url: "http://localhost:32400"
plex_token: "your-plex-token"
plex_match_every_mins: 1440
Watchlist
Add something to your Plex watchlist and zurg fetches it. Every new watchlist item is searched on your indexers. A Newznab indexer gives an NZB. It drops into the Usenet backend exactly as a Sonarr grab or a Stremio play would. The three share the naming rules so they find each other's grabs instead of duplicating them. A Torznab indexer gives a torrent. It is added to a debrid account only when that account already has it cached. prefer decides which kind is tried first. A movie becomes one release. A show is acquired season by season and season packs come before loose episodes. A season nobody posted a pack of falls back to the loose episodes the search surfaced, the best release for each. The item leaves the watchlist only once the release behind it has been checked and only when remove_after_grab allows it. Failures stay on the list. They are tried again after 5, 10 and 15 minutes, then after an hour, then further and further apart, and a title that has failed for two weeks is parked until you resume it on the acquisition page. See persistence and recovery.
It needs a plex_token and at least one indexer. With no list of its own it borrows the Stremio addon's. The monitor talks to Plex's cloud service, so plex_server_url is not required. NZBs need an nzb provider on the same instance. Torrents need a debrid account that takes them. Either one is enough.
Every option below is on the config page under Plex Watchlist, indexer list included; the dashboard writes this block rather than the legacy flat keys, and switching the monitor off there clears plex_watchlist_enabled too, since that one is ORed into the switch.
The acquisition block also takes concurrency (default 8, 1 to 200), how many titles are searched, checked and placed at once, and indexer_concurrency (default 3, 1 to 16), how many calls are made to one indexer at once; keep the latter low when an indexer key is shared.
exclude_words has no copy in this block. To keep words such as DV or HDR10+ out of watchlist grabs, add an acquisition: block holding only exclude_words. Every setting above still applies. See excluding releases by name.
plex_token: "your-plex-token"
watchlist:
enabled: true
check_every_secs: 60
max_size_gb: 40
max_season_size_gb: 100
quality: best
indexers:
- name: my-indexer
url: https://indexer.example
api_key: your-api-key
Acquisition sources
acquisition.sources lists the services zurg acquires for, each on the shared queue described in acquisition sources. The search settings (indexers, quality, prefer, max_size_gb, max_season_size_gb) sit beside sources in the acquisition: block and fall back to the watchlist: block above.
best_first belongs to the acquisition: block alone and is off by default. Turned on it makes a radarr or sonarr source's first grab follow the quality profile's order of preference. The best allowed resolution comes first. Anything at or above the cutoff counts as the cutoff. When a release fails zurg moves down the profile. When it is off a movie takes the allowed release that is cheapest to check. A series takes the one quality ranks first. The upgrade pass heads for the cutoff afterwards. The best release is more often a large archive set so the first file tends to arrive later. See taking the profile's best first.
exclude_words (list, default empty) sits there too and has no watchlist fallback. A release whose name has one of these words is never taken, from any source. Matching is by whole word and ignores case, so DV does not catch DVDRip. DV, DoVi and Dolby Vision count as one word, and so do HDR10+ and HDR10Plus. HDR10 and HDR10+ are different words. An entry with no letter or digit is refused at startup. The full rules and how they combine with Radarr and Sonarr profiles are in excluding releases by name.
acquisition:
indexers:
- name: my-indexer
url: https://indexer.example
api_key: YOUR_INDEXER_API_KEY
sources:
- name: movies
type: radarr
enabled: true
url: http://radarr:7878
api_key: YOUR_RADARR_API_KEY
library_path: /data/zurg/__magic__
- name: shows
type: sonarr
enabled: true
url: http://sonarr:8989
api_key: YOUR_SONARR_API_KEY
library_path: /data/zurg/__magic__
Plex Library Maintenance (same host only)
These need Plex installed on the same machine as zurg, because they read its database file directly. plex_server_url is frequently another host or container and no HTTP endpoint can hand over that file, so they are driven by an explicit path and stay off until you set one.
zurg only ever reads this database, through a read-only handle. It never writes to it and never stops Plex. The one file it creates is a snapshot in its own data/plex-backups/, never in Plex's directory.
Why snapshots, when Plex already backs itself up nightly: the danger is the mount being disturbed while Plex is mid-scan, which can empty a library in seconds. A nightly backup can be 24 hours stale at that moment; one taken just before zurg touches the mount is seconds old.
What the trash sweep will never remove
Plex's trash is not a list of things that may be deleted. It is a list of candidates, and a large share of them are wrong:
- An item that still has a file. Plex tombstones an entire movie when a single one of its versions disappears, so a trashed item routinely has versions that still play. The sweep drops the phantom version instead, which brings the item back out of the trash with its collections, artwork, watch state and
added_atintact. If that repair fails for any reason, the item is left alone — it stays in your library with its trash icon. It is never deleted. - A show or season whose episodes are still there. These carry no files at all, so nothing about the mount can justify removing one.
- Anything whose file it did not individually confirm missing, immediately before removing it.
Removal is per item rather than Plex's own emptyTrash. emptyTrash is section-wide and would take exactly the entries above, which is the one thing the sweep must not do.
zurg already knows whether it is coming back
For every absent file the sweep asks zurg's own repair state before reaching for the timer:
An item with several versions is judged as a unit: one version still under repair keeps the whole entry, and an entry is only known dead when every version is. A failed lookup is never a licence to delete — anything zurg cannot vouch for is handled by the age window below, exactly as before.
The trash icon is a status, so removal waits
Plex has three states, and only the middle one is visible as a problem:
The trash icon is the only notice you get that content has gone missing, and purging the entry takes that notice away along with it: the item stops being visibly broken and simply stops existing. So an entry zurg has no verdict on is left alone until it has carried the icon for plex_trash_sweep_min_age_days. That window is your chance to see it and re-acquire; anything still broken afterwards was junk worth clearing.
It also acts as a third safety layer under the caps and the mount probe — a transient mount problem resolves itself long before the window elapses.
The grace period governs removal only. A repair puts a playable item back in your library, so it happens on the next sweep regardless: waiting would leave a working item wearing a trash icon for no reason.
None of these states touch files. The file was already gone from the mount — that is what caused the tombstone — and nothing is ever deleted from your debrid account.
When it refuses to run
- zurg is not managing rclone (
rclone_enabled: false) — it cannot vouch for a mount it does not own, so "the file is missing" means nothing. - rclone is not running, or the mount does not read. The probe opens and reads a file rather than calling
stat, because astatis answered from rclone's directory cache and keeps succeeding long after reads have stopped working. - Plex is scanning. A scan in flight is still deciding what is missing.
- Either cap would be exceeded. This is the important one: a mount that came back empty but readable passes every other check, and makes the whole library look deleted. Going over a cap aborts the sweep entirely, removes nothing, and says so in the log.
A snapshot is taken before anything is removed, and a snapshot failure aborts the sweep.
Turning it off: set
plex_trash_sweep_every_mins: 0, or remove the key. Changing it needs a zurg restart. That turns off zurg's removal only. To have Plex empty its own trash again, use the Let Plex Empty Its Own Trash switch on the dashboard, or listautoEmptyTrashinplex_settings_ignore.
plex_database_path: "/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
plex_trash_sweep_every_mins: 360
plex_trash_sweep_min_age_days: 14
plex_trash_sweep_max_items: 50
plex_trash_sweep_max_percent: 10
Related Plex setting, outside zurg: turn off Plex's Settings → Library → Empty trash automatically after every scan. It is enabled by default in Plex and is unsafe on a zurg mount — a scan that meets a briefly unreadable mount deletes the library permanently instead of parking it in the trash, where it would come back with the mount. zurg turns it off itself under the default
guardpolicy, and says so on the dashboard and in the log. If your files move between library folders and you need that cleanup back, the config page's Let Plex Empty Its Own Trash switch hands it to Plex again at your own risk.
Other Integrations
jellyfin_server_url: "http://localhost:8096"
jellyfin_token: ""
emby_server_url: "http://localhost:8096"
emby_token: ""
dmm_api_key: ""
3. Playback & File Structure
Controls what files your media server sees and how content is streamed.
Streaming Mode
disable_stream_proxy: false
serve_strm_files: false
save_strm_files: false
What disable_stream_proxy does and does not reach
It governs the mount. It does not reach the /strm/ endpoint, on any backend:
A .strm is opened by a player zurg has no session with, so the endpoint can only hand over a URL the player can fetch itself — turning the proxy on does not route that traffic back through zurg. The consequence worth knowing: reads that arrive from .strm files get no bandwidth accounting, no mid-stream failover to another account, and no broken-link detection, all of which a mount read gets. A library served mainly through .strm therefore sees little from disable_stream_proxy either way.
serve_strm_files and disable_stream_proxy are independent, despite what older versions of this page said. A .strm is served as text before any streaming decision is reached, so the two can be enabled together — though with serve_strm_files on, every playback arrives at the /strm/ endpoint, which is exactly the row above where disable_stream_proxy changes nothing.
File Filtering & Detection
Built-in playable formats are .avi, .flv, .m2ts, .m4v, .mkv, .mov, .mp4, .mpg, .mpeg, .ts, .webm, .wmv, .mp3, .flac, .m4a, and .m4b.
addl_playable_extensions:
- m3u
- cbz
force_select_playable_files: false
delete_torrent_if_extensions_found:
- zipx
- rar
Metadata & Naming
retain_folder_name_extension: false # deprecated
retain_rd_torrent_name: false # deprecated
ignore_renames: false
Both deprecated flags key the library on Name, which the account rewrites
underneath zurg; the defaults key it on the name the torrent declared, which the
account leaves alone. Setting either logs a warning at startup, and on an
instance fronting Sonarr or Radarr (a sabnzbd: or qbittorrent: block) the
defaults are the only correct values — an access key that moves after an import
loses the release. Behaviour is unchanged this release: nobody's folder names
move. docs/naming.txt is the full set of naming rules.
Mount Writes
A WebDAV DELETE through the mount is always honoured — there is no key for it. It removes a file from the library, and the release from every debrid account holding it once nothing is left. Know what that costs before pointing a writer at the mount: rclone flushes an overwritten file as DELETE followed by PUT, so anything that rewrites a file in place (an .nfo writer, a trickplay pass, an *arr rename, a stray touch) deletes content instead of replacing it, and the mount carries rclone's own credentials so authentication cannot tell the two apart. Only the PUT half being refused keeps that sequence from completing quietly. mount_read_only: true is what stops a delete reaching zurg at all.
A refused write answers 403 and logs the config key that would allow it, so a client whose write stopped working says why in logs/zurg.log.
One aftermath to know about: when a program writes through the FUSE mount, rclone's VFS cache keeps the modified copy and retries the refused upload on a backoff, and the mount shows the locally-modified file until that cache entry is discarded — the server-side file is untouched the whole time. mount_read_only: true avoids this entirely by failing the write at the kernel, before rclone caches anything.
dav_allow_rename: true
See also mount_read_only, which makes the kernel refuse the write before it reaches zurg.
Directory listing cache
disable_listing_cache: false
The cache keeps the last rendering of each top-level directory, one per mount flavour (/dav, /infuse, /http), and hands it back until the library changes. What counts as a change is not a guess: it is exactly the set of events that already make rclone forget its own cached copy of the same listing — a release added, removed, renamed, refiled, a file going broken or healing — and rclone holds those listings for twelve hours. So a listing served from here is never staler than the one the mount is already serving, and there is a hard 60-second cap on top of that as belt and braces.
__magic__/__all__ — the mirror, and the only listing in the namespace worth caching, since the root above it holds the mirror and a handful of rows — additionally tracks the stored layout, so an *arr import invalidates that one listing and leaves every other directory's rendering intact. __downloads__ and Depth: 0 requests are not cached at all.
Responses carry an X-Zurg-Listing: hit or miss header, so a live install can be measured:
curl -s -o /dev/null -D - -X PROPFIND -H 'Depth: 1' http://localhost:9999/dav/__all__/ | grep -i x-zurg-listing
__magic__, a directory you can organise
Every other directory is a saved filter — a release is in movies because it matches the movies filter — so there is nowhere in the library to put something. __magic__ is the exception: __magic__/__all__ is an exact copy of __all__ and the root beside it is yours, and anything can be moved out of the mirror to anywhere else in the namespace. A move rewrites a row in data/magic.journal, compacted into data/magic.json; no bytes move, no torrent is renamed, and the rows key on the release's content hash, so a repair that rebuilds the release does not lose where you put things. It is what lets Radarr and Sonarr import by rename instead of by copy.
Full write-up, including what survives a repair and what each refusal means, in docs/magic.md.
magic:
enabled: true
allow_delete: false
sidecar_max_mb: 32
sidecar_budget_mb: 2048
dav_allow_rename does not apply here, and neither does the mount's ungated delete path. Those cover the routes that rename and destroy what the debrid account holds; a write under __magic__ reaches no account. mount_read_only: true still overrides everything, at the kernel.
Two things worth knowing. The filter directories are untouched by design, so a release you moved inside __magic__ is still in recent and movies under its own name — point a media server at __magic__ or at the filters, not at both. And anything genuinely new written into the mount (an .nfo, a subtitle, a poster) lands in zurg's own data/local and is merged into the listing, which is why sidecars beside a release simply work.
Sidecar files, and what the two caps are for
The embedded mount is a union whose first upstream is data/local, so when a program creates a new file on the mount, rclone writes it there and zurg is never asked. Nothing caps that tree — unless union_writable: serverPUT and is refused outside __magic__ and bounded by the two caps inside it.
With the default order, the two caps are for the other kind of client: one that mounts /dav directly, with no union in front of it. Those send zurg the PUT, and zurg writes the body into data/local/__magic__/<path> — the very directory the union serves — so the two views show one directory rather than two. Without a bound, that is a general file store reachable over WebDAV, which is what sidecar_max_mb and sidecar_budget_mb exist to prevent.
Two rules follow from what __magic__ is, and both are refusals a client may see:
- The mirror is the library's own.
__magic__/__all__and the release folders directly inside it are computed, so aMOVE,MKCOLorDELETEaimed at either is 403: a row there would be a second address for a release that already has one. Deeper is a release's own folder, where a write is as ordinary as anywhere else — it is where an *arr puts the.nfobeside a file it imported. - Nothing the library answers for may be written over. A release folder, an entry of one, and a path a move has placed something at are all rendered from the library, so a
PUTor aMOVEof a real file onto any of them is 403. That includes a name inside a release folder the release does not have: the folder's listing is the release's, so a file put among its entries would be listed by nothing. - A placement never destroys a real file. Moving something the library holds onto a path a sidecar occupies is refused rather than overwriting it, even with
Overwrite: T. The same holds for a directory of real files moved the other way: it may not be renamed onto a path the library answers for, because its files would then sit in a folder that lists the release and nothing else.
A DELETE of a sidecar really deletes it, which is the one place in __magic__ where a delete is not a tombstone: a tombstone hides an entry the library still holds elsewhere, and a sidecar's bytes live in data/local and nowhere else.
The __magic__ page
The dashboard has a page of its own for the namespace, at /magic/, linked from the dashboard's quick links whenever __magic__ is configured or served. It is where the table is visible, and it is the only place two of its states can be reached at all.
It reports how many rows are stored — placements, tombstones and directories, counted separately — what the journal and the snapshot take on disk, how much of sidecar_budget_mb the sidecar tree is using, and the size of the whole of data/local. That last number is the one to watch: a client that copies rather than moves puts the bytes there, so a data/local climbing into gigabytes means something is importing the wrong way round. The rows themselves are grouped by release; a very large table is truncated on the page and says by how much, and the counts above it are always of the whole table.
The stored rows list shows the first thousand rows, and an *arr leaves a tombstone behind on every import, so on an organised library most rows are past that point. A search box on the stored rows and dangling pages finds one release by name, by a path on the mount or by its hash, and lists every row stored about it with its buttons. data/magic.json is not a way round that: zurg writes it out from memory when it stops, so a row deleted from it by hand while zurg runs comes back.
Three buttons, all of which go through the same one operation — a row ceasing to exist:
- Reset a placement, and the file or folder goes back to where the library puts it.
- Unhide a tombstone, and the entry is listed in
__magic__again. Nothing was destroyed to hide it. A release in__all__that never shows up in__magic__/__all__, even after deleting it and adding it again, has a release tombstone: the rows are keyed on its hash, which a re-add does not change. Search for it and unhide that row. - Delete a dangling row — one whose release, or whose file inside it, the library no longer holds. Such a row resolves to nothing, so it cannot be listed, moved or deleted through the mount, and it is kept rather than dropped because a repair that restores the release restores where you put it. Until this page there was no way to clean one up; Clear all dangling rows sweeps them in one go.
Sidecars are listed as well, and the ones nothing accounts for any more are listed apart from the rest and counted. Those are the sidecar's equivalent of a dangling row: an .nfo or a poster written beside a release that has since left the library, or inside a folder whose placement has been forgotten. They are still served — a real file is the last thing a __magic__ path resolves to, and nothing above them is claiming the name any longer — so the only thing that has gone is the reason they were put there. zurg never sweeps one: a file left beside a release that was deleted is still yours, and a repair that brings the release back accounts for the folder again. A directory you made for sidecars yourself, through the mount rather than through zurg, reads the same way here, because that leaves no row either.
Deleting a sidecar from the page really deletes the file, the same way a DELETE through the mount does. Every button changes what the mount has cached for the paths involved, because the mount holds a directory listing for twelve hours with polling off.
If the SABnzbd endpoint is on, the page also lists the jobs it has been handed — the id the client knows each by, its category, whether the release has arrived in the library yet, and the folder to import from once it has. Sonarr and Radarr remove their own jobs. A Failed or Stuck job that none of them will ever remove can be cleared from the page. That happens when zurg is no longer their download client. See sabnzbd.md.
Both the magic: and the sabnzbd: block are editable in full from the __magic__ and the SABnzbd endpoint are registered once, at startup, out of these values. Turning either on writes it to config.yml and changes nothing about the run that answered — the __magic__ page says so too rather than rendering an empty namespace.
sabnzbd, the download client Sonarr and Radarr see
zurg can answer Sonarr and Radarr as though it were a SABnzbd. They hand it an NZB, it writes the file into nzbs/ for the Usenet backend, and once the release is in the library the job reports Completed with a job folder under __magic__/__all__ to import from. Nothing is downloaded to import: the *arr renames the file inside the mount, which is a row in the __magic__ table.
It needs both halves to be useful — an nzb provider to read the NZB, and magic.enabled to have somewhere to import from — and it is off until asked for. Full setup, including what to put in the *arr, is in docs/sabnzbd.md.
sabnzbd:
enabled: true
api_key: ""
categories: [tv, movies]
complete_dir: ""
The API key is the whole of the authentication, so treat the endpoint the way you treat the rest of zurg's port: on a trusted network, or behind something that is. Basic auth cannot be used for it — the clients never send it, and a 401 is reported to the user as "unable to connect" rather than as an authentication failure.
qbittorrent, the torrent download client Sonarr and Radarr see
zurg can answer Sonarr and Radarr as though it were a qBittorrent. They hand it a magnet or a .torrent and zurg adds the info hash to a debrid account. Once the release is in the library the torrent reports finished with a folder under __magic__/__all__ to import from. Nothing is downloaded to import. The *arr renames the file inside the mount and that is a row in the __magic__ table.
Two halves make it useful. An account that can add torrents reads the magnet and magic.enabled gives the *arr somewhere to import from. It is off until asked for. Full setup including what to put in the *arr is in docs/qbittorrent.md.
qbittorrent:
enabled: true
api_key: ""
categories: [tv-sonarr, radarr]
save_path: ""
download_timeout_mins: 15
Fifteen minutes is a measured figure rather than a chosen one. AllDebrid parks a healthy job in its own queue with every counter at zero before it moves a byte. Two runs out of two sat there for 580 and 622 seconds. The captures are in docs/torrent-lifecycle.md.
Which account a grab goes to is decided by add_torrentsproviders: list. A grab that timed out or that an account gave up on is deleted from that account before the next one is tried. Only an instance zurg added itself for that grab and that never finished is ever a candidate. Nothing else this endpoint answers deletes anything from a debrid account.
The API key is the whole of the authentication. Treat the endpoint the way you treat the rest of zurg's port. Put it on a trusted network or behind something that is. Basic auth cannot be used for it. The clients never send it and they read a 401 as a hard authentication failure they never retry.
The stremio addon
zurg can be a Stremio addon that searches your own Newznab indexers and plays through the Usenet backend. It needs an nzb provider and at least one indexer. It is off until asked for. Setup and what a play does are in stremio.md.
Media Analysis
auto_analyze_new_torrents: true
ffprobe_binary: "ffprobe"
4. Health & Repairs (Automation)
The "set it and forget it" section. Controls how zurg keeps your library healthy by detecting and fixing broken torrents automatically.
enable_repair: true
repair_every_mins: 60
repair_timeout_mins: 30
stalled_download_mins: 10
restrict_repair_to_cached: false
only_full_torrent_repair: false
check_for_changes_every_secs: 15
library_detail: resident
library_detail_idle_secs: 300
downloads_every_mins: 720
delete_error_torrents: false
on_library_update: |
for arg in "$@"
do
echo "detected update on: $arg"
done
Repairing across accounts
Every other repair strategy re-adds a release to the account it came from, which is the right shape for as long as that account can still produce the content. It is the wrong shape for one case.
The services keep independent caches. A release Real-Debrid has purged, or one it refuses on the filename alone, can be sitting cached on AllDebrid right now, under the same info hash. Nothing is wrong with the release. Only the account is wrong.
So when a release has been given up on by every account that holds it, zurg offers it to the configured accounts that do not, in the order they appear in your providers block. The first one that already has it takes it, zurg lists that account, and the release picks up a second copy the way any multi-account release does: its own file ids, its own links, read failover between the two. The verdict on the entry clears itself once a copy that can be repaired exists.
There is no setting. Configuring a second account is the decision, and zurg already lists it, serves from it and fails reads over to it; asking it about a release the first account cannot produce is the same arrangement. It follows enable_repair like every other repair.
The repair button on a release does this too, and in the right order: it retries the account that holds the release first, exactly as it always has, and only offers the release around if that attempt fails. That is the one path that works with the repair sweep switched off entirely.
What keeps it from becoming an add loop is the shape of the offer rather than a switch:
- An account is only ever handed content it already holds. The offer is an add that is withdrawn and deleted again unless the account completes it within fifteen seconds. An account that would have to fetch the release off the swarm is left exactly as it was found, having spent no transfer allowance. This is a repair, never a re-download.
restrict_repair_to_cacheddoes not control this and is never read here: that key is about an account repairing its own copy, and this path is cached-only whatever it says. - Only a release nothing can serve. A working release is never copied around, and neither is one repair has not given up on yet.
- An account that says no is asked less and less. The wait after a miss starts at a day and doubles with each further miss, up to about a fortnight. A release that is merely out of cache this week is found again within days; one that is gone for good settles into a fortnightly question. Without that decay, always-on would mean a daily add for every dead release on every account, for ever.
- At most ten releases a sweep, so the first sweep after a second account is added works through a backlog over hours rather than in one burst.
An account that already holds a copy of the release is never offered a second instance of it, however broken the copy it holds is: repair's own strategies own that account, and on Real-Debrid a duplicate add never downloads at all. Releases whose key is not a bittorrent info hash, which is how the Usenet backend identifies its own, are never offered to anything.
This is the one place zurg adds to an account whose content expires without being asked. Repair will not re-add a release to such an account on a timer, because re-fetching content the service deliberately removed is the pattern those services call abuse. A cached offer is not that: nothing is fetched, no allowance is spent, and the content being claimed is already in the service's cache because somebody else put it there.
Library detail
library_detail: resident remains the default. Choose a mode and idle time
in the dashboard's configuration page or in config.yml. Both settings take
effect after restarting zurg; saving them does not partly switch a running
library. The policy applies to every provider in the instance.
Names, sizes, renames and visibility remain available to listings in every
mode. Opening or repairing a file rebuilds released details on demand. auto
does not eagerly reload the library when pressure ends: details return as they
are used and stay resident until pressure and the idle window allow eviction
again.
Automatic pressure detection
auto samples every 30 seconds. Two readings at least 30 seconds apart at or
above 90% of a measured budget start eviction, in batches of at most 128
idle tables per sweep. Eviction continues until usage reaches 80% or less,
then pauses for at least a minute before considering a new episode. Brief
spikes do not start eviction. The configured idle window always applies.
The tightest available reading drives the decision:
-
Host memory: total minus available memory, including the OS's estimate of reclaimable caches. A full filesystem cache alone is not a shortage of RAM.
-
Linux containers and service limits: cgroup v2
memory.maxandmemory.high, or v1memory.limit_in_bytes, with each visible ancestor's own usage. Cold file cache is discounted. Container limits are reread at runtime; private cgroup namespaces can hide limits above the visible root. -
An explicit runtime budget: a positive
memory_limit_mb, in MiB, compares against Go-managed memory.GOMEMLIMIT, when set, takes precedence;GOMEMLIMIT=offdisables this particular signal. Host and container pressure still apply. The automatic Go ceiling derived from Usenet caches is ignored because it rises with the live heap and does not measure available RAM.
No additional budget is required to enable auto:
library_detail: auto
library_detail_idle_secs: 300
# Optional explicit Go runtime budget, also used by the collector:
# memory_limit_mb: 1024
Without a usable measurement, automatic eviction pauses. This is a gradual
memory optimization, not a hard memory cap: active files, unsaved changes,
recovery snapshots, and other caches may dominate memory, and sudden allocation
spikes can outpace the sweeper. memory_limit_mb remains a soft collector limit;
setting it below the live data can cause excessive garbage collection.
Data integrity and cost
Eviction retains a compressed recovery snapshot as well as the existing
data/*.zurgtorrent dump. A disk reload must match that snapshot. If the dump is
missing, damaged or replaced, zurg restores the snapshot and keeps the release
resident, then retries saving the recovered data. auto also retries idle dirty
tables when pressure has ended, with a separate bounded save pass. File writes
and state changes restore ownership immediately, including
writes through a pointer held by a stream across eviction. Failed saves leave
changes resident and dirty; overlapping writes are not acknowledged by an older
save. A successful save is still required for changes to survive a process exit.
Measured on an Apple Silicon Mac with Go 1.26.4, using 500 releases per shape through the conversion and persistence paths, including the recovery snapshot:
These are retained heap figures per release, not whole-process RSS. Rebuilds
read recently written local files; cold or remote storage can take longer.
Choose lazy for consistent idle reclamation or auto to reclaim only under
pressure. Keep resident when predictable access matters more than RAM. Small
releases save little, and the recovery snapshot deliberately spends memory to
protect data. These measurements describe evicted entries in both lazy and
auto; automatic mode may retain more entries at any given time.
5. Network & Connectivity (Technical)
Infrastructure settings that control how zurg binds, authenticates, and connects to the network.
Server Binding
host: "[::]"
port: 9999
Warm connections
Opening a connection to a debrid service is expensive and using one is nearly free. Measured from a live host on 2026-08-29, a fresh connection to a Real-Debrid download host costs a TCP round trip and then 68–85 ms of TLS handshake before a byte of the file has been asked for — and zurg's HTTP transport opens nothing until a read wants it, dropping an unused connection again after 90 seconds. On anything but a busy library, the read that follows a quiet spell pays for that on the viewer's own path.
A warm floor moves it off that path: each account keeps a stated number of connections dialled and handshaken ahead of demand, so the transport has one in hand when a read arrives.
warm_connections: 2 # every account keeps 2, unless it says otherwise
providers:
- type: realdebrid
token: YOUR_RD_API_TOKEN
warm_connections: 4 # this one keeps 4 to each host it uses
- type: torbox
token: YOUR_TORBOX_API_KEY
warm_connections: -1 # and this one keeps none
How much this buys you depends on the host, and for the debrid services it is less than it sounds. A connection that has sent nothing is the first thing these services reap, and they do it quickly. Timed from a live host on 2026-08-29, holding a silent connection open and waiting to be hung up on:
So a floor is worth keeping to a download host and not much else, and Real-Debrid's API cannot be warmed at all — no floor outruns a four-second hang-up without manufacturing traffic to keep the connection alive, which zurg will not do. Rather than assume, the floor measures: it retires its own connections at 15 seconds and checks whether each was still alive when it did. A host that keeps closing them before a read could use them is given up on, and its reads dial as they always did. Nothing has to be configured for that, and a floor going unread on a quiet library is not mistaken for it.
Two more things worth knowing before raising it:
- A debrid account's floor is per host, a news account's is per account. A debrid account is reached over many hosts — an API hostname, and a delivery host per link — and which ones is not knowable until a link has been minted, so the hosts kept warm are the ones the account has actually been using. At most 16 are kept at once, and one unused for 10 minutes is retired. A news account is one host, named in the config, and holds its session for as long as the plan allows — which is why its floor is the one that reliably pays off. See Usenet.
- Warm connections are real connections, and count against the per-host ceiling the provider polices — 32 on Real-Debrid and AllDebrid, 20 on TorBox. That is why the debrid floor is capped at 4: a floor that size leaves the ceiling essentially intact, where a large one would spend it on sockets carrying nothing.
The floor is disabled when proxy is set, since an HTTP or SOCKS proxy makes zurg dial the proxy rather than the host, and there is nothing on the far side worth holding open.
Authentication & Proxy
username: ""
password: ""
proxy: "http://[username:password@]host:port"
Rclone Settings
rclone_enabled: false
rclone_binary: "rclone"
rclone_extra_args: []
mount_read_only: false
rclone_cache_reclaim: true
union_writable: "local"
Benchmark-Optimized Defaults
Zurg uses benchmark-tested rclone VFS settings optimized for streaming performance:
--vfs-cache-max-size, --vfs-cache-min-free-space, --low-level-retries, --retries, --vfs-handle-caching, --log-level, --cache-dir and --log-file are constants in internal/rclone/manager.go; every other row comes from tuneVFSOptions in internal/rclone/tuning.go — --vfs-cache-max-age and --vfs-cache-poll-interval included, despite the names.
Disk the mount uses
--vfs-cache-mode full means a file read through the mount is downloaded in
full to data/rclone-cache and kept there after playback ends. It is not a
playback scratch buffer that empties when the video stops. Left alone, the cache
grows to --vfs-cache-max-size, which defaults to 256G, and stays there.
Two limits are supposed to bound it, and in practice only one does:
-
--vfs-cache-max-size(256G) is the real bound. Set it to no more than half the free space on whatever diskdata/sits on. -
--vfs-cache-max-age(72h) evicts a file nothing has touched for three days. rclone measures that from its own record indata/rclone-cache/vfsMeta/, not from the filesystem's atime, and anything that reads the library refreshes it. A Plex server with scheduled tasks enabled reads every cached file during its nightly maintenance window, so on 2026-09-09 an instance whose cache held 362 files had an ATime from that same morning on every one of them. Nothing had aged out in months, and nothing ever would. Do not plan around age eviction; plan around the size cap.
Lower the cap with rclone_extra_args:
rclone_extra_args:
- "--vfs-cache-max-size"
- "50G"
- "--vfs-cache-min-free-space"
- "20G"
--vfs-cache-min-free-space (10G by default) stops caching when the disk gets
that close to full. Keep it well above zero on a disk shared with anything else.
To reclaim the cache by hand, stop zurg first, then empty both trees:
rm -rf data/rclone-cache/vfs/* data/rclone-cache/vfsMeta/*
Measure it with du -sh data/rclone-cache. Partially downloaded entries are
sparse files, so a size that counts apparent bytes reports far more than the
disk actually holds.
When the cache costs more than it buys
full is the right default for a media server on the same host: it reads a file
once, and everything after that is local. It is not automatically right for a
hardware player reading the mount over a file share.
Under full a read has to land on disk before it reaches the reader, and what
gets fetched is the whole file rather than the part being watched. Put a share
in front of that and one disk is serving the player while rclone is still
writing to it. A Zidoo player reading a Windows share stuttered on every title
until the cache was turned off, and played cleanly afterwards (reported
2026-08-30). The mechanism above is the plausible reading of that report rather
than something measured here, so treat it as the first thing to try when a
direct-reading client stutters on a mount that a media server on the same host
plays without trouble:
rclone_extra_args:
- "--vfs-cache-mode"
- "off"
What that gives up is real. With no local copy a seek re-reads from the provider, a second play downloads the file again, and nothing survives a restart. It also changes what the mount accepts as a write, so leave the default alone on a host where an *arr imports through the mount — see qbittorrent.md.
Caches stranded by a move
rclone names its cache after the remote it is caching, and that name carries a
hash of the remote's configuration. Zurg's remote is a union whose upstreams
embed the absolute path of data/local, so the name moves when the
configuration does:
rclone only ever accounts for the tree it is using. A tree under an old name is counted against no cap, visited by no cleaner, and deleted by nothing — so moving the zurg directory, or an external drive that comes back under a new mount point, leaves a whole cache behind. Several such moves is how an install with a 256G cap ends up holding multiple terabytes.
Zurg reclaims those trees at mount start: it asks the running rclone which tree
is live and deletes the rest, logging what it freed. It does nothing unless the
live tree is on disk where rclone says it is, and it skips the sweep entirely
when rclone_extra_args sets --cache-dir, since a directory you chose may be
shared with another zurg. Set rclone_cache_reclaim: false to turn it off and
keep every stranded tree.
Four more are added by platform, all of them on Linux: --allow-other and --allow-non-empty always, --uid and --gid only when the running user's ids can be read. --max-read-ahead is passed everywhere except Windows, and --read-only whenever mount_read_only
The buffer and read-wait values were determined through benchmarking on a 600 Mbps connection, achieving ~533 Mbps cold-cache throughput and ~235ms TTFB. The read chunk size was re-measured later on a ~70 MB/s host: paired and interleaved, median throughput went 49.1 -> 56.5 MB/s on Real-Debrid moving it from 4M to 32M, and moved the same way on TorBox.
Override defaults using rclone_extra_args:
rclone_extra_args:
- "--buffer-size=128M" # Use less memory
- "--vfs-read-chunk-size=2M" # Smaller chunks
RC flags (--rc, --rc-addr, and anything else starting with rc-) are rejected: zurg runs rclone's remote control itself to invalidate the VFS cache when the library changes.
User Agent & Network
user_agent: "Mozilla/5.0 ..."
force_ipv6: false
unrestrict_ip: ""
network_test_every_mins: 1440
dns_servers:
- "1.1.1.1:53" # Cloudflare
- "8.8.8.8:53" # Google
Public NZB sharing
An NZB share is a URL a recipient fetches — so it only helps if they can reach this host, which a zurg behind a home NAT cannot promise. These two keys route that URL through a zrok service instead: zurg dials the service outbound and serves the share through the connection, so no local port opens and nothing new answers on this host. What the tunnel carries is only the two token-gated share routes, never the dashboard and never the library.
Both empty, and with no enabled environment under data/zrok, sharing stays on the network this host is already on, dialing nowhere — that is the default. Note the second half of that: once an environment has been enabled, an empty zrok_account_token no longer means local-only, because the enable is what turns sharing on and it persists. Turning public sharing back off means removing data/zrok.
Building, serving and revoking shares work the same either way — the share token in the path is the authorization on the local URL and the public one alike.
The public URL survives a restart: the service-side share is looked up and re-attached to rather than created again. What it does not survive is enabling again — a new environment gets a new share, and URLs handed out under the old one stop resolving. That happens when you remove data/zrok, and also if the identity saved in there goes missing or unreadable, since enabling again is the only way back from that.
Removing data/zrok by hand also leaves that environment, and the share under it, registered on the zrok account — the identity that could have released them is what you just deleted, so tidy them from the zrok console if the clutter matters. It is not a way to switch sharing off in place, either: a running zurg holds the connection it is already serving on, so the old URL keeps working until the process restarts. zurg releases an environment itself whenever it replaces one, and refuses to replace one it could not reach the service to release — or one whose files it finds on disk but cannot read, since enabling over that would abandon a live environment on every restart.
Reachability cuts two different ways for a self-hosted service, and only one of them is yours: this zurg reaches the controller API — a tailnet address is enough for that — while your recipients reach the service's frontend. A service reachable only on your own tailnet therefore hands out share URLs only your own tailnet can open.
Two more things a self-hosted service has to line up with. The share URL is the one the service advertises for its frontend, and a bare host with no scheme is assumed to be https — so a frontend serving plain HTTP wants TLS in front of it, or an advertised URL that carries its own scheme and port. And the share is created in the namespace the zrok SDK resolves, which is public unless the SDK's own ZROK2_DEFAULT_NAMESPACE says otherwise; zurg pins neither, so a service whose namespace is named something else is told through that variable.
zrok_account_token: "" # the enable token, needed for the first enable only
zrok_api_endpoint: "" # self-hosted only; empty = the hosted service
6. Performance & Tuning (Advanced)
These settings are rarely touched unless debugging or optimizing for your specific network conditions.
Persistent read caches
Two byte caches survive a restart. Both are shared across the instance and each has its own disk budget in MiB. A change takes effect after a restart.
The oldest unused records go first when a budget fills. persistent-caches.md covers every cache that survives a restart.
CDN & Host Selection
Related: The network latency test that determines the fastest hosts runs periodically based on
(default: every 1440 minutes / 24 hours). network_test_every_mins
rd_cdn_host_preference: "auto"
tb_cdn_host_preference: "auto"
number_of_hosts: 3
force_cloudflareandforce_location_*are mutually exclusive. RD publishes a.cloudtwin for its numbered hosts only — measured 2026-08-09, all 25 numbered subdomains have one and none of the 22 country-specific ones do. So an account with Cloudflare enabled in its RD account settings is always handed a numbered host, and no location preference can be honoured for it. Zurg detects this from the link it unrestricts at startup, skips the geo probing that cannot succeed, and logs why if a location is configured anyway.
Note on
force_location_*verification: When using a geo-location preference likeforce_location_nyk, the RD/rest/1.0/downloadsAPI endpoint normalizes download URLs to numbered servers (e.g.,128-4.download.real-debrid.com) even though the actual unrestrict response returned a geo server (e.g.,nyk7-4.download.real-debrid.com). The download code itself embeds the numbered server (e.g., code...4128maps to server128-4). To verify that geo routing is working, check zurg's logs or the raw unrestrict response — do not rely on the RD downloads API.
Rate Limits
api_rate_limit_per_minute: 250
torrents_rate_limit_per_minute: 75
fetch_torrents_page_size: 5000
Link Verification
use_range_verification: false
Timeouts & Retries
api_timeout_secs: 60
download_timeout_secs: 15
retries_until_failed: 2
retry_503_errors: false
Downloads & Debugging
get_downloads_limit: -1
load_dumped_torrents: false
log_requests: false
log_level: DEBUG
traffic_alert_gb: 500
7. Directories & Filters
Define virtual directories that organize your torrents based on flexible filter rules. Each directory appears as a folder in your mount and shows only the torrents matching its filters.
directories:
shows:
group: media
group_order: 20
filters:
- has_episodes: true
# ... (see config.example.yml for full examples)
Directory options
A file explicitly forced visible in zurg ignores all three visibility options.
Filter conditions
Tags come from media analysis — see tags.md for the full list.
Patterns are written /expression/flags, where the flags are any of i (case-insensitive), m (multi-line), s (dot matches newline) and x (extended). A value with no slashes is compiled as a bare Go regular expression.
Conditions within one filter entry are not ANDed by default — most of them match the entry as soon as one of them holds. Use an explicit and: block when you mean all of them:
directories:
anime:
group: media
group_order: 10
filters:
- and:
- has_episodes: true
- any_file_inside_regex: /^\[/
- not_regex: /\[One Pace\]/
Per-account directories
Alongside __all__, every configured account gets a directory of its own, named after it — __realdebrid__, __torbox__, __alldebrid__, __nzb__. They are not configured; they appear because the account exists, and disappear when it is removed.
Each one holds whatever that account holds, and — unlike a filtered directory — it also decides where reads go. A release held by two accounts is one entry in the library, so without this every copy of it would stream from whichever account sorts first in the providers block:
__realdebrid__/Some.Release/movie.mkv → streamed from Real-Debrid
__torbox__/Some.Release/movie.mkv → streamed from TorBox
The pin excludes the other accounts rather than merely preferring the named one. There is no silent failover: if TorBox cannot serve the file, the read under __torbox__ fails instead of quietly answering with Real-Debrid's bytes. Reads elsewhere in the mount are unaffected and still fail over normally.
Two consequences follow from accounts not holding identical content:
- A directory lists only the files its account actually holds. If TorBox downloaded part of a release Real-Debrid has whole,
__torbox__shows the part. - Brokenness is judged per account. A file dead on Real-Debrid but healthy on TorBox is a 404 under
__realdebrid__and plays under__torbox__, while the rest of the mount still serves it.
The directory is named after the account's name, which defaults to its type. Two accounts on one service need distinct names (rd-main, rd-backup) and get a directory each. The Usenet backend is nzb by default, so it appears as __nzb__; set name: usenet on that provider entry if you would rather browse __usenet__.
You can rename an account later. zurg sees the same releases listed under the new name and moves them there. The old directory goes away with the old name. If a media server reads that directory point it at the new one before you rename.
Because the same release appears under every account holding it as well as in your filtered directories, point a media server at one part of the mount rather than all of it, or it will scan the same content several times.
An account named after a directory zurg already uses (all, unplayable, dump, downloads) gets no per-account directory, and zurg logs a warning at startup rather than taking the existing one over.
Local debrid catalogs
Each debrid provider entry accepts an optional library block:
providers:
- name: rd-local
type: realdebrid
token: PRIMARY_RD_TOKEN
download_tokens: # Optional bandwidth fallback.
- SECOND_RD_TOKEN
library:
source: local
path: torrents/rd-local
source defaults to provider. With local, portable .zurgtorrent files own
membership and playback uses this account's credentials. path defaults to
torrents/<resolved-account-name> and must stay under torrents/. The same
source directory cannot belong to two enabled accounts. NZB providers retain
their existing nzbs/ library.
In this example, both RD tokens serve the one catalog in torrents/rd-local/.
The second token needs no provider entry or directory, and its torrent library
is not imported. download_tokens keeps its bandwidth-fallback behavior; it
does not force every download through the second token. Two separate local
provider entries instead own two distinct catalog directories, even when both
are Real-Debrid. Credentials stay in the config and are never exported into
portable files.
See portable libraries for offline export, migration
from dump/, sharing semantics and playback limits.
Offloading an online account into a local library
A Real-Debrid or AllDebrid account that owns its catalog (no source, or
source: provider) can move its finished releases into a local library and
delete them from the account, which frees their slots:
providers:
- name: rd
type: realdebrid
token: YOUR_RD_TOKEN
library:
offload_to: rd-local # a local entry of the same type
offload_after_mins: 30 # optional, 30 is the default
- name: rd-local
type: realdebrid
token: YOUR_RD_TOKEN # the same account
library:
source: local
A release moves only when every file has a link another account could
redeem, nothing is repairing or downloading it, no Sonarr or Radarr job in the
qBittorrent endpoint still lists it, and moving it keeps its folder name and
every directory it is in. Its folder stays where it was and no media server
scan is started. The release page in the dashboard can move one release by
hand, with or without offload_to. See
portable libraries
for the order of the steps, what happens when one fails, and how local playback
on a shared token is protected.