Nodes and Multiple Machines - Kheopsian/Hydranos GitHub Wiki

🇬🇧 English · 🇫🇷 Français

Nodes and Multiple Machines

Applies to Hydranos 4.4.

This page is for you if you run Hydranos on more than one machine and want to see and move torrents between them from one web UI. It covers adding a machine (enrol or declare), what the enrolment script does to it, and which actions work on torrents of another machine. Whether you need several machines at all: Deployment Topologies.

What a node is

A node is another complete Hydranos instance: the same hydranos program, with its own config file, store, engines, API key and web UI. There is no special node mode and no separate node daemon.

  • The instance where you add a node is its controller. The controller keeps the node's name, URL and API key in its own store (table nodes in hydra.db), not in default.toml. The key is stored in plain text.
  • The relationship goes one way. The controller calls the node's normal HTTP API with the node's API key (header X-API-Key). The node never calls the controller after enrolment, and it does not store the controller's address or key.
  • A node keeps running on its own. If the controller is down or deletes it, the node keeps seeding and its own UI keeps working. Removing a node on the controller only deletes the row (Remove node {name}? Its own data is untouched.).
  • Torrent data never goes through the API. When a torrent moves between machines, only the .torrent file goes over HTTP. The bytes go over BitTorrent between the two engines' listen ports, and each piece is hash-checked.
  • Every call from the controller to a node (health probe, list, relayed action, handoff, fetch, key rotation) has a 4-second HTTP timeout.
  • These calls never use the daemon's egress route, its [proxy] setting or a tunnel, even with the kill switch armed (an HTTP_PROXY/HTTPS_PROXY set in the daemon's environment is not switched off, so the HTTP client's default may still apply it): they leave by the host's normal route (VPN and Egress).

There are two ways to add a node:

+ Enrol a machine + Declare an existing one
Use it for a fresh Linux machine with nothing installed an instance that is already running (any OS, including Windows)
What you type one curl … | sh command, on the new machine name, URL and the node's API key, on the controller
Who installs Hydranos the script (Docker, a binary with a system unit, or a binary in one user's home) you, beforehand (Docker, Linux, Windows)
Does the controller test the node first no yes, it refuses a node that does not answer

Enrol a new machine

On the controller, open the Nodes tab and click + Enrol a machine. The controller creates a one-time token and shows a command like this:

curl -fsSL http://192.0.2.10:8199/install.sh | sh -s -- --register-to http://192.0.2.10:8199 --token 0123456789abcdef0123456789abcdef
  • The token is 32 hex characters, valid for 30 minutes and usable once. The duration is fixed in the code and cannot be changed.
  • The address in the command is the one your browser used to reach the controller (HTTP Host header). If that is localhost or a name the new machine cannot resolve, open the UI by an address it can reach and click again.
  • The command always uses http://, even behind an HTTPS reverse proxy: the token and the node's new API key travel unencrypted.
  • Copy copies the command; Close hides the card (the token stays valid).

Read this before you pipe the script to sh

The script is the file install.sh from the repository root, built into the binary and served without authentication at /install.sh. Run it on a Linux machine. It installs Hydranos in one of three ways, picked in this order:

Mode When What it installs
Docker docker is on the PATH a container
system no Docker, run as root /usr/local/bin/hydranos and a systemd unit
user no Docker and not root (a seedbox account), or --user ~/.local/bin/hydranos and a systemd --user unit

The Docker mode writes to /opt/hydranos by default: run it as root, or pass a --dir you can write. Here is what the script does:

  1. It reads its options: --register-to and --token (both required), --name (default: the output of hostname -s), --port (default 8199), --dir (see step 4), --container (Docker only, default hydranos), --image (Docker only, see step 5), --user (force the user mode). Any other option stops it with exit code 2.

  2. It finds the address the controller will use to reach this machine: the source address of ip route get <controller>. If that fails, it stops.

  3. It picks the release to install: the controller's own version, read from the controller's /health (tag v<version>), so a node runs the same release as its controller even when enrolled months later. The environment variable HYDRANOS_VERSION_PIN overrides it with a tag such as v4.4.0 (curl … | HYDRANOS_VERSION_PIN=v4.4.0 sh -s -- …). If neither gives a version, Docker uses the image :latest and the binary modes ask GitHub for the latest release.

  4. It creates the config and data folders:

    Mode Config file Data folder data_dir written in the file
    Docker <dir>/default.toml (/opt/hydranos) <dir>/data /configs, the folder as the container sees it
    system <dir>/default.toml (/opt/hydranos) <dir>/data <dir>/data
    user $XDG_CONFIG_HOME/hydranos/default.toml (default $HOME/.config/hydranos) $XDG_DATA_HOME/hydranos (default $HOME/.local/share/hydranos) the data folder

    In user mode, --dir replaces both: <dir>/default.toml and <dir>/data. If the config file does not exist, it writes one with a new random API key (48 hex characters):

    [daemon]
    api_host = "0.0.0.0"
    api_port = 8199
    api_key = "<48 hex characters>"
    data_dir = "<see the table>"
    
    [race]
    listen_port = 16371
    enable_ipv6 = true
    
    [hoard]
    listen_port = 16372
    enable_ipv6 = true

    An existing file is kept and its api_key reused. In user mode a new file is made readable by its owner only (mode 600): on a shared seedbox the key is the node's whole authority. BitTorrent ports are 16371 (race) and 16372 (hoard), not the usual 16171/16172: open them (TCP and UDP).

  5. Docker mode. If a container with the --container name already exists, the script replaces it only if it carries the label io.hydranos.enrolled=true, that is, if this script made it. Any other container stops the script with a container named hydranos already exists and was not made by this script; pass --container <another-name>, or remove it yourself. Then it runs:

    docker run -d --name hydranos --label io.hydranos.enrolled=true --restart unless-stopped --network host \
      --stop-timeout 150 \
      -v /opt/hydranos:/configs -v /opt/hydranos/data:/data \
      ghcr.io/kheopsian/hydranos:v<controller version> --config /configs/default.toml

    --image replaces the whole image name. The container uses the host network, so ports 8199, 16371 and 16372 are the host's ports. The config folder is mounted at /configs, and the store lives in /opt/hydranos on the host because data_dir = "/configs".

  6. System mode (root, no Docker). It downloads hydranos-<tag>-linux-amd64.tar.gz (or -arm64; any other architecture stops it) from the GitHub release, installs /usr/local/bin/hydranos, writes /etc/systemd/system/hydranos.service (rewritten on every run; it runs as root, with Restart=always, TimeoutStopSec=150 and LimitNOFILE=1000000), then runs systemctl enable and systemctl restart. Without systemctl, it prints the command to start Hydranos and exits with an error, before registering.

  7. User mode. It installs the same tarball's binary in $HOME/.local/bin/hydranos, then:

    • if systemctl --user works in this login, it writes $XDG_CONFIG_HOME/systemd/user/hydranos.service (default $HOME/.config/systemd/user; Restart=always, TimeoutStopSec=150; no LimitNOFILE, the unit raises the open-file limit to the account's maximum instead), enables and restarts it. It then tries loginctl enable-linger for the account. If lingering stays off, it warns lingering is off for <user>: the node stops when you log out and stays down after a reboot. and asks you to have the host run loginctl enable-linger <user>;
    • otherwise (no user manager, for example an ssh login without pam_systemd), it starts Hydranos with nohup, writes its PID to <data>/hydranos.pid, and prints the command to start it again: nothing restarts it after a crash or a reboot. A re-run stops the previous process (same binary only) and waits up to 150 s for it.
  8. It polls http://127.0.0.1:<port>/health up to 60 times, one second apart, each try allowed 2 s: between about 1 and 3 minutes. A try counts only when /health answers and no longer says "status": "starting" (the port opens while the catalogue still loads). If the node never gets there, it exits without registering and names where the logs are.

  9. It posts {token, name, url, api_key} to the controller's /api/nodes/register, with url = http://<address from step 2>:<port>.

  10. If registration fails, it prints the URL and the API key so you can add the node by hand with + Declare an existing one, and exits with an error. The node stays installed and running.

A name that is already declared on the controller is refused (HTTP 409) before the token is spent, with a node named <name> already exists; the token is still valid, try --name: run the same command again with --name <another name>.

Other limits of the script:

  • Linux only (sh, ip, getent, curl, /dev/urandom, and tar outside Docker). There is no enrolment script for Windows or macOS; declare those instances instead.
  • An IPv6-only route to the controller would produce a URL with the IPv6 address written without brackets, which the controller cannot use. Use IPv4, or declare the node by hand.
  • Re-running the command on an enrolled machine keeps its config and key. It reinstalls on the controller's version (Docker: recreates its own container; system: replaces the binary and rewrites the unit), then registration ends in 409 (name taken), without spending the token: the node row is already there.

Node names

A name may contain - (seedbox-de, the placeholder of the Declare form, or a hostname -s default). A remote row is labelled <node>-<engine>, and the UI and the server match that label against the declared names, longest first: with a node nas-2, the row nas-2-hoard is node nas-2, engine hoard. A name cannot contain / or ..: it is a path segment of the Open link.

The enrolment sequence

sequenceDiagram
    actor Admin
    participant C as Controller
    participant N as New machine
    Admin->>C: Nodes tab, + Enrol a machine
    C->>C: POST /api/nodes/enrol, token valid 30 min, single use
    C-->>Admin: curl command built from the browser's Host header
    Admin->>N: paste the command in a shell
    N->>C: GET /install.sh (no authentication)
    C-->>N: the script
    N->>N: own address = source of ip route get controller
    N->>C: GET /health (no authentication)
    C-->>N: the controller's version, the release to install
    N->>N: write default.toml with a new API key (if absent)
    N->>N: Docker container (replaces only one labelled by the script),<br/>or binary + system unit (root), or binary + user unit (--user)
    N->>N: poll GET /health on 127.0.0.1 until it is not starting, 60 tries
    N->>C: POST /api/nodes/register with token, name, url, api_key
    alt missing token, name or url, loopback URL, or / or .. in the name
        C-->>N: 400, token not spent
    else name already known (checked before the token)
        C-->>N: 409, token not spent, try --name
    else token unknown, already used or expired
        C-->>N: 401
    else name taken meanwhile (checked again after the token)
        C->>C: token spent
        C-->>N: 409, existing node not overwritten
    else accepted
        C->>C: token spent, node stored, node not probed<br/>an empty api_key is accepted
        C-->>N: 200
    end
    opt registration failed
        N-->>Admin: prints URL and API key for + Declare an existing one
    end
    Admin->>C: open the Nodes tab later
    C->>N: GET /api/status and GET /api/engines (live probe)
Loading

Declare an existing instance

Use + Declare an existing one for an instance already installed: Docker, Windows, a machine where the script failed, or an upgraded 3.x machine.

  1. On the node, find its API key (Config › Advanced settings (default.toml) › General › api_key, or [daemon] api_key in its default.toml). See Security and Access.
  2. On the controller, Nodes tab, + Declare an existing one, then fill in:
    • Name: no / or .. (see Node names above).
    • URL: the node's origin, as other machines reach it, for example http://198.51.100.5:8199. A trailing / is removed.
    • API key (the remote's own): the node's key, not the controller's.
  3. Click Test. It probes the node without saving anything. On success it shows Hydranos <version>, <engines>, <n> torrents. On failure it shows the reason (see the status table below).
  4. Click Add. The controller probes again and refuses (HTTP 400) if:
    • the name or URL is empty, or the name contains / or ..;
    • the URL host is a loopback address (127.x.x.x, localhost, ::1, 0.0.0.0): Open sends the browser there, so other machines must be able to use it;
    • the probe fails.

Add with a name that already exists still replaces its URL and key, without asking. To change a node, use Edit on its row instead (see below).

An https:// URL is accepted (for a node behind a reverse proxy). This has not been tested for 4.4, and a handoff to such a node may not reach it (see Handoff below).

The Nodes tab

The tab loads once when you open it. It does not refresh on its own.

  • This node: the local engines (columns Engine, Role, Listen port, Interface, IPv6, Socket, Torrents, Actions) and + Add an engine. Engines are covered in Engines: Race and Hoard.
  • Other nodes: one row per declared node, probed live each time the tab loads (all nodes at once). Columns: Name, URL, Version, Engines (from the node's GET /api/engines), Torrents (sum over all its engines), Status, Actions. With no node: No other node.
    • Open (only when the node is online) opens the node's own UI in a new tab, already logged in: the controller redirects the browser to <node URL>/#key=<node API key>, and the node's page moves the key into its own browser storage.
    • Edit changes the node's name, URL or key (see Edit a node).
    • Rotate key (greyed while the node is offline) gives the node a new API key (see Rotate a node's key).
    • Remove deletes the row on the controller. Nothing happens on the node. A move to that node still waiting on the Jobs tab then fails, and the local copy is kept.
    • Version is shown only. Nothing compares the controller's and the node's versions, and no route refuses a mismatch. Mixed versions are not tested: keep every machine on the same release.

Status values (the error strings come from the server and are shown untranslated, in French without accents):

Status Meaning What to check
online /api/status answered with JSON —
timeout no answer within 4 s node down, firewall, wrong port
connexion refusee the connection could not be set up: refused, DNS failure, no route, TLS handshake port, host name, firewall, https vs http
injoignable the request failed after the connection was set up reverse proxy, node restarting
cle API refusee node answered 401 or 403 the node's key changed: declare it again with the new key
HTTP <code> any other non-success status a reverse proxy between the controller and the node
an error saying the answer is not from Hydranos 200 but not JSON something else listens on that URL

There is no "enabled" switch: the store's enabled flag is never set to false.

Edit a node

Edit opens the Declare form filled with the node's name and URL. The button reads Save, and the API key field is empty with the placeholder unchanged: the stored key is never sent to the browser, and an empty field keeps it. Save sends PATCH /api/nodes/<current name>:

  • A new URL or key is probed first, as on Add: refused (400) if the probe fails or the host is a loopback address. The form always sends the URL, so the probe always runs: an offline node cannot be renamed from the UI (the API accepts {"name": …} alone, without a probe).
  • A new name follows the same rules as on Add (no / or ..). A name already taken is refused with 409 a node named <name> already exists. If the URL or key changed in the same save, they are already stored when that 409 comes back.
  • Moves to this node still waiting on the Jobs tab follow the new name.
  • Test in this form probes with the key field as typed: left empty, it tests without a key.

Rotate a node's key

Rotate key asks Rotate the API key of {name}? Then:

  1. The controller asks the node for a new key (POST /api/auth/api-key/rotate on the node, with the current key). The node generates it and keeps it pending, in memory, for 10 minutes; the current key still works.
  2. The controller confirms by presenting the new key (POST /api/auth/api-key/confirm). The node writes it to [daemon] api_key in its default.toml and refuses the old key from the next request. Browser sessions logged in with a password on the node stay valid.
  3. The controller stores the new key and shows it once (New API key of {name} — Shown once. Copy it for the clients of this node that used the old key.). Give it to whatever else used the old key: Sonarr, Radarr or autobrr pointed at the node, scripts. Open hands the new key to the browser.

Errors: the node is unreachable, the node refused the current key: declare it again with its key (fix it with Edit), the node is too old to rotate its key (needs 4.4). If the controller stops between the two steps, the node keeps its old key and forgets the pending one after 10 minutes or a restart.

What works across nodes

What the controller merges:

  • The Hoard list (/api/hoard/page) shows the rows of every node next to local rows. The Location column shows local-<engine> for a local row and <node>-<engine> for a remote one (the API field is still named agent, a legacy name). The node's own merge is included, so a node's extra hoard-role engines appear too. A node that errors or takes more than 4 s is left out of the page without a warning. Filter chip counts are summed over all nodes.
  • Ctrl+A on the Hoard list ("everything the filter matches") includes remote rows.
  • The Race tab does not show remote rows: it reads the local race engine only.

The table below gives the result of each context-menu action from the controller's UI. "Relayed" means the controller sends the action to the node that holds the row, on the node's own route for that engine's copy; a refusal comes back as <node>: <the node's error>. "Skipped" means the bulk task reports {n} not on this node (or {n} not applicable for a race row) and does nothing. Bulk tasks and their progress panel are described on Jobs, Bulk Actions and Moving Data.

Action Local row Remote row (<node>-<engine>)
Stop / Start works relayed (POST /api/engines/<engine>/pause on the node)
Reannounce works relayed
Recheck (shown when the selection holds a hoard row) works, race rows included relayed
Remove torrent / Remove + delete files works, on the selected copy only relayed: removes the node's copy in that engine
Change category › (label only) works relayed
Change category + move files › works (job or graduation) relayed: the node moves its own files (a job on the node). A refusal for hardlinks comes back as an error, without the consent prompt
Set location… works relayed, as above
Limit rate…, Sequential download, First/last piece first works relayed
Edit tags ›, Force download / Stop forcing works on hoard rows skipped
Share limits… works skipped
Duplicate to engine › local-<id> (same files, second identity) works not offered
Move to engine › local-<id> (here, no copy) works not offered
Duplicate to engine › / Move to engine › <node>-<engine> (handoff) works not offered
local-<id> (bring it here) (fetch) not offered works; offered under both Duplicate and Move, and the remote copy is always kept
Move to engine › <node>-<engine> (there, no copy) not offered works, between two engines of the node that holds the row
Detail panel, Edit trackers works the detail is looked up locally and does not load; Edit trackers edits this instance's copy of the same hash, if any
Export (zip, hash list, CSV) works .torrent files come from the local store only; remote-only hashes go to missing.txt in the zip

For the skipped items, click Open on the Nodes tab and do it in the node's own UI. A remote row whose node is no longer declared fails with no declared node holds <label>.

Known bug in 4.4: Edit trackers in the detail panel of a row from another node appears to edit that node's torrent — the detail of a remote row does not load, and the button edits this instance's own copy of the same hash, if there is one — click Open and edit the trackers in the node's own UI.

The Node group (Duplicate to engine ›, Move to engine ›) appears as soon as a node is declared or this instance has two local engines (every default install has race and hoard). The engine picker lists this instance's engines first, then one section per node; an offline node shows offline, a node without engines no engine, and if nothing applies you see Nowhere else to send it. A selection that spans several nodes, or mixes local and remote rows, gets no target.

Handoff, fetch and move

Handoff: push a local torrent to a node

Select local rows, then Duplicate to engine › or Move to engine › and pick <node>-<engine>.

  1. If the torrent has a category, the controller asks the node for its categories (a torrent without one, or a node whose category list cannot be read, skips this check). If the node has some, but not this one, the handoff fails and lists them: create the category on the node (with its own save path) and retry.
  2. It uploads the .torrent to the node (POST /api/torrents/upload, with category and the chosen engine). The node places it as for any add (Categories and Routing).
  3. It tells the node where the data is (POST /api/torrents/<hash>/peers with auto:<port>). The node replaces auto by the IP the request came from and dials the controller's engine on that port. If the node sits behind a reverse proxy, that IP is the proxy's and the dial goes nowhere, while the task still reports success.
  4. The node downloads the data from the controller over BitTorrent.

Duplicate stops there: both instances keep a copy, and the bulk task reports success. Move (then: remove) also creates a handoff job on the controller, and the bulk task reports {n} sent; the local copy goes once the node holds it (Jobs). On the Jobs tab (Jobs, Bulk Actions and Moving Data) the job shows the state Waiting for the node, the destination <node>-<engine> and the node's progress:

  • Every 30 s the controller asks the node for the torrent's progress (GET /api/engines/<engine>/page?search=<hash> on the node; every engine of the node when none is named). Any engine works, extra ones such as vpn1 included.
  • When the node reports 100 %, the job is Done and the controller removes the torrent from every local engine, with its files (files that another torrent still reads are kept).
  • A failed poll is ignored and polling goes on. The deadline, 6 hours after the handoff, is stored in the job: a controller restart resumes the wait. Past it, the job fails with the node did not report a full copy within 6 hours; the local copy is kept.
  • If the node is removed meanwhile, the job fails with the node <name> was removed before it confirmed; the local copy is kept. A rename with Edit is followed.
  • Cancel on the Jobs tab stops the wait: both copies are kept.
  • A second move of the same torrent to a node while one waits is refused (409): a move of this torrent to a node is already waiting; see Jobs.
sequenceDiagram
    actor Admin
    participant C as Controller
    participant N as Node
    Admin->>C: Move to engine, pick node-hoard
    opt torrent has a category
        C->>N: GET /api/categories
    end
    alt node has categories, not this one
        C-->>Admin: error listing the node's categories
    else category known, node has none, no category, or the categories call failed
        C->>N: POST /api/torrents/upload with .torrent, category, engine
        C->>N: POST /api/torrents/HASH/peers with auto:PORT
        C->>C: job handoff, state waiting, deadline in 6 h
        C-->>Admin: bulk task: sent, the local copy goes once the node holds it (Jobs)
        N->>N: auto = IP of the caller, queue a dial
        N->>C: BitTorrent connection to the controller's engine
        C-->>N: pieces over BitTorrent, hash-checked on the node
        loop every 30 s while the job waits, resumed after a restart
            C->>N: GET /api/engines/ENGINE/page?search=HASH
            Note over C,N: a failed poll is ignored, polling goes on
        end
        alt node reports progress 1
            C->>C: job done, remove the torrent from every local engine, with its files
        else deadline passed
            C->>C: job failed, local copy kept
        else node removed on the controller
            C->>C: job failed, local copy kept
        else Cancel on the Jobs tab
            C->>C: job cancelled, both copies kept
        end
    end
Loading

Fetch: bring a remote torrent here

Select rows of one node, then Duplicate to engine › or Move to engine › and pick local-<id> (bring it here). The controller:

  1. refuses if the engine is not a local engine (400) or if this instance already has the torrent (409);
  2. downloads the .torrent from the node (GET /api/torrents/<hash>/torrent) and reads the listen port of the node's engine that holds it (GET /api/engines);
  3. adds the torrent to the chosen local engine, without a category, and dials the node on that port.

The data then comes over BitTorrent. A fetch never removes anything on the node: remove its copy in the node's own UI (Open) if you want.

Move between engines of a node

On remote rows, Move to engine › <node>-<engine> (there, no copy) asks the node to move the torrent to its other engine (POST /api/torrents/<hash>/engine on the node). Nothing crosses the network; the node's answer is passed through.

Over the REST API

The same operations exist as routes on the controller: POST /api/nodes/:name/handoff ({info_hash, engine, category, then, from}; with then: "remove" the answer carries the job id), POST /api/nodes/:name/fetch ({info_hash, engine, from_engine, category}, from_engine defaults to hoard), POST /api/nodes/:name/move-engine ({info_hash, engine}). See REST API.

Managing nodes:

  • PATCH /api/nodes/:name: any of {name, url, api_key}, at least one (else 400 nothing to change: send name, url and/or api_key). Answers {"status":"ok","name":…,"health":…}; 404 for an unknown node.
  • POST /api/nodes/:name/rotate-key: answers {"status":"ok","name":…,"api_key":<new key>,"health":…}; 502 with the reason when the node does not rotate.
  • On any instance, the two steps of a rotation: POST /api/auth/api-key/rotate (current key in X-Api-Key; answers {"pending_key", "expires_in": 600, "confirm"}), then POST /api/auth/api-key/confirm with the pending key in X-Api-Key. They also rotate the key of an instance that is not a node (Security and Access).
curl -X POST http://192.0.2.10:8199/api/nodes/seedboxde/handoff \
  -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"info_hash":"<40-hex info hash>","engine":"hoard","then":"keep"}'

What is never aggregated

These stay per instance, even with nodes declared:

  • Adding torrents: the + Add Torrent tab, watched folders and the shim add to local engines only. To put a torrent on a node from here, hand off one the controller already has.
  • Header and Overview totals, rates, records and benchmark figures.
  • The Race tab, the Trackers tab and tracker statistics.
  • The qBittorrent shim (/api/v2/*): Sonarr, Radarr, autobrr and cross-seed see the controller's torrents only. Point each tool at the instance that should hold its torrents (qBittorrent Shim and Automation).
  • Workflows: they run on the instance where they are defined, on its own torrents.
  • Jobs, the drain, categories and configuration.
  • MCP: read tools list the merged rows, but write tools act on local torrents only (MCP Server (AI clients)).

Security

  • The controller holds every node's API key, in plain text in its store. Anyone with the controller's API key or UI login can act on every node, and Open hands a node's key to any browser logged in to the controller. The enrolment card says the controller "never holds a credential" for the node: that is not true in 4.4. What is true: the controller never gets a shell or SSH access to the node. If the controller's key or store leaks, rotate every node's key with Rotate key, and the controller's own key (Security and Access).
  • /install.sh and /api/nodes/register need no API key; register accepts only a valid, unexpired, unused token (expired tokens stay in the store, harmlessly).
  • Enrolment and node calls use plain HTTP by default. Keep nodes on a private network or a VPN between your machines. For HTTPS, put the node behind a reverse proxy and declare it with its https:// URL (see the handoff caveat above). See Security and Access.
  • Each node exposes its own API on port 8199 (api_host = "0.0.0.0" with the enrolment script). Firewall it like any other instance.

See also

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