Docker Install - Kheopsian/Hydranos GitHub Wiki

🇬🇧 English · 🇫🇷 Français

Install with Docker

Applies to Hydranos 4.4.

This page is for anyone running Hydranos in a container: plain docker run, Docker Compose, Unraid, or a NAS container manager such as Synology's. At the end you will have a container that keeps its state across upgrades, stops cleanly when Docker stops it, writes files as your user, and is ready for the first-run screen. Terms used here (store, resume state, category) are defined in Concepts and Glossary.

Short version: pin a version tag, mount /config and one data folder, keep stop_grace_period: 150s and restart: unless-stopped. The restart buttons of the web UI stop Hydranos the same clean way as docker stop and rely on that restart policy to bring it back (see Stop grace period below).

Image

Name ghcr.io/kheopsian/hydranos
Tags vX.Y.Z for each release (with the v, e.g. v4.4.0) and sha-<short commit>. A release does not move latest: that tag is only written by a manual run of the build on the default branch, so do not rely on it
Architectures linux/amd64, linux/arm64
Base debian:bookworm-slim
Entrypoint /usr/local/bin/entrypoint.sh, which ends by exec-ing hydranos, so the daemon is PID 1 and receives Docker's SIGTERM directly

Images are built when a release tag is pushed, or when the maintainer runs the build by hand. There is a single binary in the image, hydranos; releases no longer ship a hydranos-engine. Pin a version tag (:v4.4.0) rather than :latest: you then decide when an upgrade happens, and you can take a backup first.

The older image name ghcr.io/kheopsian/hydra is deprecated: do not use it, pull ghcr.io/kheopsian/hydranos.

The image declares no VOLUME and no HEALTHCHECK. If you forget to mount /config, everything Hydranos knows lives in the container layer and is gone the next time the container is recreated.

flowchart LR
  subgraph HOST["Docker host"]
    P1["8199/tcp"]
    P2["16171/tcp + 16171/udp"]
    P3["16172/tcp + 16172/udp"]
    D1["./config"]
    D2["./data"]
  end
  subgraph CT["container"]
    EP["entrypoint.sh"] --> HY["hydranos process"]
    C1["/config"]
    C2["/data"]
    HY --- R["engine race<br/>listen 16171"]
    HY --- HO["engine hoard<br/>listen 16172"]
  end
  P1 -->|"web UI, REST API, qBittorrent shim, MCP"| HY
  P2 --> R
  P3 --> HO
  D1 --- C1
  D2 --- C2
  C1 -->|"config, store, resume state, log"| HY
  C2 -->|"torrent data, via category save paths"| HY
Loading

Compose file

This is the reference compose file for Hydranos 4.4. It follows the docker-compose.yml shipped in the repository; the differences that matter are a pinned tag instead of :latest (see Image above) and PUID/PGID set rather than commented out.

services:
  hydranos:
    image: ghcr.io/kheopsian/hydranos:v4.4.0
    container_name: hydranos
    # The web UI's restart buttons exit with code 75 and rely on this.
    restart: unless-stopped
    # Time Docker waits after SIGTERM before it sends SIGKILL.
    # Hydranos tells the trackers it is leaving (5 s at most), then writes
    # every engine's resume state within HYDRANOS_STOP_TIMEOUT (120 s by
    # default). Keep this above that budget. See below.
    stop_grace_period: 150s
    # One open file per peer connection: the entrypoint raises the open-file
    # limit to 1000000, which only works if the hard limit allows it.
    ulimits:
      nofile:
        soft: 1048576
        hard: 1048576
    # Network mode "WireGuard, managed by Hydranos" only (see Managed WireGuard):
    # it needs NET_ADMIN; the sysctl keeps the IPv6 side of a dual-stack .conf.
    # With PUID/PGID, also set HYDRANOS_CAP_NET_ADMIN: "1" below.
    # cap_add:
    #   - NET_ADMIN
    # sysctls:
    #   net.ipv6.conf.default.disable_ipv6: 0
    ports:
      - "8199:8199/tcp"      # web UI, REST API, qBittorrent shim, MCP
      - "16171:16171/tcp"    # race engine, peers (TCP)
      - "16171:16171/udp"    # race engine, peers (uTP)
      - "16172:16172/tcp"    # hoard engine, peers (TCP)
      - "16172:16172/udp"    # hoard engine, peers (uTP)
    environment:
      # Run as your user (see "User and permissions"). Remove both lines to run as root.
      PUID: "1000"
      PGID: "1000"
      # Shutdown write budget: 120, 90s, 2m or 1m30s. Raise stop_grace_period with it.
      # HYDRANOS_STOP_TIMEOUT: "120"
      # Managed WireGuard with PUID/PGID: keep NET_ADMIN after the privilege drop.
      # HYDRANOS_CAP_NET_ADMIN: "1"
    # Docker's json-file driver keeps everything unless told otherwise.
    logging:
      driver: json-file
      options:
        max-size: "128m"
        max-file: "5"
    volumes:
      - ./config:/config     # config, store, resume state, log
      - ./data:/data         # your torrent data: ONE parent folder (see Volumes)
      # Migrating from qBittorrent? Use the container path qBittorrent had
      # (e.g. /downloads) instead of /data: see Migrating from qBittorrent.

Start it:

mkdir -p config data      # create them yourself, so they belong to your user
docker compose up -d
docker compose logs -f hydranos

Create the two folders before the first up: a bind-mount folder that does not exist is created by Docker, owned by root, and a container running with PUID/PGID then cannot write into /data.

The same container with docker run:

docker run -d --name hydranos --restart unless-stopped \
  --stop-timeout 150 \
  --ulimit nofile=1048576:1048576 \
  --log-opt max-size=128m --log-opt max-file=5 \
  -p 8199:8199/tcp \
  -p 16171:16171/tcp -p 16171:16171/udp \
  -p 16172:16172/tcp -p 16172:16172/udp \
  -e PUID=1000 -e PGID=1000 \
  -v "$PWD/config:/config" -v "$PWD/data:/data" \
  ghcr.io/kheopsian/hydranos:v4.4.0

For the managed WireGuard mode, add --cap-add NET_ADMIN --sysctl net.ipv6.conf.default.disable_ipv6=0 (and -e HYDRANOS_CAP_NET_ADMIN=1 with PUID/PGID); the requirements are on Managed WireGuard.

Ports

Publish each engine's port on TCP and UDP: uTP uses UDP on the same number. Keep the host port equal to the container port: an engine announces its own listen port to trackers, so 26171:16171 would advertise a port nobody can reach. The full port list (including extra engines) is on Installation and First Run.

If you change api_port in default.toml, change the mapping too. Rescue mode (store cannot be opened) answers on the same api_host/api_port as normal mode.

Under the default bridge network, automatic port forwarding (NAT-PMP/UPnP, on by default) is unlikely to reach your router. If the log keeps saying no automatic port forward; if you are not reachable, forward <port> (TCP and UDP) by hand, forward 16171 and 16172 (TCP and UDP) on the router yourself. Details: Networking.

Stop grace period: why 150 s

On SIGTERM, Hydranos stops in three bounded steps, then exits:

  1. API drain, 5 s at most. The web UI's event stream and live log tail close themselves when the stop begins, so an open tab no longer holds the drain; anything still open after 5 s is dropped (connections still open 5 s into the drain …; flushing without them).
  2. Departures, 5 s at most: each engine tells its trackers it is leaving (stopped).
  3. Resume-state write, one thread per engine in parallel, bounded by HYDRANOS_STOP_TIMEOUT (default 120 s; 120, 90s, 2m, 1m30s and 1h are accepted). It logs shutdown flush complete (with took_s), or shutdown flush hit HYDRANOS_STOP_TIMEOUT; leaving with what is saved when the budget runs out first.

Docker's default is to send SIGKILL after 10 s. If the kill lands during the write, the next start works from the last periodic save of resume state (every 300 s): progress and counters since then are lost, and what was in flight is re-checked. The worst case is 5 + 5 + 120 = 130 s, so 150 s leaves room. If you raise HYDRANOS_STOP_TIMEOUT for a large library, raise stop_grace_period with it (keep it at least 10 s above); read the took_s value after a stop to see how long the write really takes. More on Operating Hydranos.

Compose copies stop_grace_period into the container's own stop timeout, so a plain docker stop hydranos uses it too. A docker stop -t 10 given by hand overrides it.

A restart from the web UI (for example Restart now after an engine change) goes through the same three steps, then exits with code 75 instead of 0; restart: unless-stopped brings the container back. Without a restart policy, the container stays stopped. See Operating Hydranos.

Optional: a health check

The image has none. You can add one; /health needs no API key. The image has no curl or wget, so the probe uses bash. Match the whole status word: a plain grep -q healthy also matches "unhealthy", so it would pass a daemon that answers 503.

    healthcheck:
      test:
        - CMD
        - bash
        - -c
        - exec 3<>/dev/tcp/127.0.0.1/8199 && printf 'GET /health HTTP/1.0\r\n\r\n' >&3 && grep -qE '"status":"(healthy|degraded|starting)"' <&3
      interval: 60s
      timeout: 10s
      retries: 3

Port 8199 opens in the first second, before the torrents are loaded: while they load, /health answers starting (HTTP 200), so no long start_period is needed. The probe passes on starting, healthy and degraded, and fails on unhealthy (HTTP 503: the store does not answer, or no engine is listening for peers), on rescue mode (plain ok) and when nothing answers. Drop degraded from the pattern to also flag an engine without a listener, blocked by the kill switch or held by start_paused. The states are explained on Monitoring and Logs. Docker marks the container unhealthy; it does not restart it.

Volumes

Container path What it holds
/config default.toml (config file), hydra.db (the store, with -wal/-shm beside it), bench.db, one folder per engine (race/, hoard/, and any extra engine) holding state.db (resume state) and resume/, hydranos.log. jeprof.* heap profiles appear only if you send the process SIGUSR1.
/data Nothing by default. It is only a mount point for your torrent data.

What the store and the resume state are, and how they differ: Architecture.

On the first start, the entrypoint copies the image's default config to /config/default.toml and sets data_dir = "/config" in it, so in Docker the config dir and the data dir (data_dir, not the /data mount) are the same folder. The whole of /config is what you back up (stop the container first): Operating Hydranos.

There is no default save path. Nothing in Hydranos writes to /data on its own: a torrent is refused unless it has a save path or a category that has one. Create categories whose save paths are container paths inside a mounted volume, for example /data/race and /data/library. A save path outside every mount writes into the container layer and disappears on recreate. See Categories and Routing.

If Sonarr, Radarr or another tool reads the same files, mount the same host folder at the same container path in both containers, so the paths Hydranos reports exist on their side too: qBittorrent Shim and Automation. If you are moving from qBittorrent, keep the container path it used: Migrating from qBittorrent.

One parent mount, for hardlinks

Hardlinks and instant moves only work inside one mount. Linux refuses link() and rename() between two bind mounts with EXDEV, even when both sit on the same disk. That affects Hydranos's own Duplicate data links and moves, and Sonarr/Radarr hardlink imports.

So mount one parent folder that holds both downloads and media, at the same container path in every container:

host:  /srv/data/torrents/...   /srv/data/media/...
mount: /srv/data:/data          in Hydranos AND in Sonarr/Radarr

Then point Hydranos categories under it (for example /data/torrents/tv) and the *arr library under /data/media. Two separate mounts such as /srv/downloads:/data/downloads and /srv/media:/data/media make every link and rename between them fail.

User and permissions

By default the container runs as root, and everything Hydranos writes is owned by root.

Set PUID and/or PGID to run as your own user. The entrypoint first raises the open-file limit (every container, before any privilege drop, so the limit is inherited). Then, when either is set and the container starts as root, it:

  1. re-points the image's hydranos account to that uid/gid (a missing one defaults to 1000), or reuses an account or group that already has that id;
  2. runs chown -R on /config (small: config, store, resume state). Set HYDRANOS_SKIP_CHOWN=1 to skip this if the permissions are already right;
  3. starts hydranos as that uid:gid.

/data is never chowned (it can hold millions of files). Make sure the uid/gid can write to the folders your save paths point to.

If you start the container with Docker's own --user, the entrypoint is not root: it ignores PUID/PGID (printing already running as uid <N>, ignoring PUID/PGID when they are set) and cannot fix /config ownership for you. The entrypoint still tries to set the open-file limit to 1000000, then 200000; if the hard limit allows neither, the limit is left unchanged.

File modes. Hydranos sets no umask and the image has no UMASK variable, so it inherits the container's umask (Docker's default is 022): new folders are 755 and new files 644, writable by their owner only. If Sonarr or Radarr must rename or delete files Hydranos created, run them with the same PUID/PGID as Hydranos.

Finding your ids. On a Linux host, id -u and id -g (over SSH on a NAS). On Unraid use 99/100 (see below). On Synology the first user created is usually uid 1026 and the group users is 100, not 1000; check with id over SSH if you can.

Environment variables

Read only by entrypoint.sh:

Variable Default Effect
PUID unset uid to run as. Setting PUID or PGID turns privilege dropping on.
PGID unset gid to run as.
HYDRANOS_SKIP_CHOWN unset 1 skips the chown -R of the config dir. Any other value does not.
HYDRANOS_CONFIG_DIR /config Config dir inside the container. The entrypoint always passes --config <dir>/default.toml, and a freshly seeded config gets data_dir = "<dir>". Mount your volume there instead of /config. Heap profiles still go to /config/jeprof.*.
HYDRANOS_CAP_NET_ADMIN unset 1 keeps CAP_NET_ADMIN across the PUID/PGID privilege drop, as an ambient capability inherited by ip and wg. Needed for the managed WireGuard mode when PUID/PGID are set; the container must also have cap_add: NET_ADMIN, otherwise the entrypoint prints CAP_NET_ADMIN not available, add --cap-add=NET_ADMIN for managed WireGuard and fwmark routing and starts without it. Running as root, cap_add alone is enough. See Managed WireGuard.
HYDRANOS_ENGINE_ID, HYDRANOS_ENGINES unset 3.x agent switches. The entrypoint refuses them: it prints hydranos: ignoring <VAR>: the 3.x agent mode was removed in 4.x … and starts a normal instance on <dir>/default.toml. Remove them to silence the message (Upgrading from 3.x).

The daemon itself reads a few more variables, among them HYDRANOS_STOP_TIMEOUT (the shutdown write budget, default 120 s, see Stop grace period above), HYDRANOS_LOG_STDOUT and RUST_LOG. Their exact parsing is on Configuration: Environment and CLI.

Unraid

The repository ships no Unraid template. Add the container by hand: Docker tab → Add Container, switch to the advanced view, and fill in (Unraid field names as they usually appear; not tested by the maintainer):

Unraid field Value
Name hydranos
Repository ghcr.io/kheopsian/hydranos:v4.4.0
Network Type Bridge (behind gluetun: see Unraid behind gluetun below)
WebUI http://[IP]:[PORT:8199]/
Extra Parameters --restart=unless-stopped --stop-timeout=150 --ulimit nofile=1048576:1048576
Port (TCP) Container 8199, Host 8199
Port (TCP) Container 16171, Host 16171
Port (UDP) Container 16171, Host 16171
Port (TCP) Container 16172, Host 16172
Port (UDP) Container 16172, Host 16172
Path Container /config, Host /mnt/<pool>/appdata/hydranos (e.g. /mnt/cache/appdata/hydranos)
Path Container /data, Host one parent share, e.g. /mnt/user/data (the same mapping as in Sonarr/Radarr, see One parent mount, for hardlinks)
Variable PUID = 99
Variable PGID = 100

99:100 is Unraid's usual nobody:users, which is what files on user shares are normally owned by. Run Sonarr and Radarr with the same ids.

Restart policy. Unraid's Autostart toggle usually only starts containers at boot; it does not set a Docker restart policy. Every restart button in the Hydranos web UI stops the process cleanly, ends it with exit code 75 and relies on Docker to start it again. Without --restart=unless-stopped, Hydranos stays down after the first such click.

Stop timeout. --stop-timeout=150 sets the container's own stop timeout, which docker stop uses when it is not given an explicit -t. The json-file size cap from the compose file can be added the same way (--log-opt max-size=128m --log-opt max-file=5) unless your Docker settings already rotate container logs.

Array stop. When you stop the array or the Docker service, Unraid stops the containers itself and may apply its own timeout rather than the container's 150 s (not tested by the maintainer). After an array stop, check the last lines of the log with docker logs hydranos 2>&1 | grep "shutdown flush": shutdown flush complete means nothing was lost. If the line is missing, stop Hydranos by hand first (Docker tab → Stop, or docker stop -t 150 hydranos), then stop the array. Behind gluetun, stop Hydranos before gluetun; the order does not affect the save itself, which only writes to /config.

Keep /config off /mnt/user

Put /config on a pool path such as /mnt/cache/appdata/hydranos, not on /mnt/user/... (derived from code, not tested on Unraid). /mnt/user is Unraid's FUSE layer, and Hydranos treats every FUSE mount like a network filesystem, where SQLite's WAL mode cannot rely on shared-memory locking:

  • a new store created there stays in SQLite's rollback-journal mode instead of WAL, which makes every write slower;
  • a store already in WAL (created on a local disk, then moved) is opened as it is; if SQLite cannot open it there, Hydranos starts in rescue mode;
  • the per-engine resume state (<engine>/state.db) is put in WAL wherever it is, /mnt/user included. Nothing protects it there: this is the main reason to keep /config on a pool path.

An Unraid "exclusive" share (Unraid 6.12 and later) is usually served without the FUSE layer and should behave like a pool path. The /data mapping can point at a user share.

Unraid behind gluetun

To run Hydranos inside an existing gluetun container (the network mode Gluetun; the managed WireGuard mode is the other way, see Managed WireGuard), change the template above as follows. Unraid field names vary between versions; not tested by the maintainer.

  1. Network Type: Container, then pick gluetun as the container network, if your Unraid version offers it. Otherwise set Network Type to None and add --net=container:gluetun to Extra Parameters. Keep --restart=unless-stopped --stop-timeout=150 and the ulimit there.
  2. Remove every Port row from the hydranos template. A container that shares another's network has no ports of its own; Docker refuses to publish ports on it.
  3. In the gluetun template, add the Port rows instead: 8199/tcp for the web UI and APIs, and the peer ports. Which peer port to open and how to set it in Hydranos (the VPN's forwarded port): VPN and Egress.
  4. WebUI: the [IP]:[PORT:8199] link may not resolve once the container has no ports. Open http://<your-unraid-ip>:8199/ directly; it reaches Hydranos through gluetun's published port.
  5. Sonarr, Radarr and autobrr then reach Hydranos at the Unraid address and port 8199, not at the name hydranos.

Synology and other NAS container managers

Synology's Container Manager can create a Project from a compose file: paste the compose file above, adapt the host paths (for example /volume1/docker/hydranos for /config and /volume1/data for /data) and the ids (see Finding your ids). Using a project keeps stop_grace_period, the ulimit and the log cap, which the single-container wizard may not expose (not tested by the maintainer). Create the host folders first in File Station.

Behind a VPN container

Two ways in 4.4:

  • Inside gluetun. The whole Hydranos container runs in the VPN container's network namespace (network_mode: "service:gluetun"), with the ports published on the VPN container instead. With the network mode Gluetun, Hydranos reads the forwarded port from gluetun's control server and follows it. Recipe and what it does and does not protect: VPN and Egress.
  • Managed WireGuard. Hydranos brings up one WireGuard tunnel per engine itself, from a provider .conf; the container needs cap_add: NET_ADMIN (see the commented lines of the compose file above): Managed WireGuard.

Unraid users: see Unraid behind gluetun above.

Logs

Hydranos always writes its log to the console, which is what docker logs hydranos (or docker compose logs -f hydranos) shows, capped by the logging: block above.

It also appends the same log to /config/hydranos.log. That file rotates itself at 128 MiB, keeping four old files (hydranos.log.1 to .4), so it takes at most about 640 MiB. Setting HYDRANOS_LOG_STDOUT to any value, including 0, turns the file off and leaves only the console. See Monitoring and Logs.

Updating

  1. Read the release notes in the changelog.
  2. Back up /config with the container stopped (Operating Hydranos).
  3. Change the tag in your compose file, e.g. :v4.4.0 → the new version.
  4. docker compose pull && docker compose up -d

Compose stops the old container with your stop_grace_period, so resume state is written before the new one starts. On Unraid, edit the Repository tag and click Apply. Nothing inside the container updates itself.

Coming from 3.x? The image name changed: read Upgrading from 3.x first.

The shipped docker-compose.yml

The docker-compose.yml at the root of the repository matches the 4.4 code. It uses :latest, which a release does not move: pin a tag as above. Its comment "keep this above HYDRANOS_STOP_TIMEOUT + 5 s" leaves out the 5 s API drain; the 150 s it sets covers both.

If something does not start, see Troubleshooting.

Next step

Now do First run on Installation and First Run: open http://<your-docker-host>:8199, create the admin account and your first category. Do this before port 8199 is reachable from the internet (Security and Access).

⚠️ **GitHub.com Fallback** ⚠️