Headless torrent client for servers. Native AOT, no shell, non-root, ~15 MB.
679
Headless torrent client for servers. Native AOT, no runtime to install, ~15.2 MB to pull.
Drop a .torrent or .magnet file into a watched directory and it downloads, then seeds.
No web UI, no daemon protocol, no database — it reads a directory and writes files.
Prefer a screen to a directory? The FrogStream apps for Android and Windows — full standalone torrent clients in their own right — can pair with this container over your LAN and drive it: browse its queue live, send it new torrents, pause, resume and remove. Nothing is exposed to the internet and no account is involved.
Get the apps: Android — Google Play · Windows — Microsoft Store
docker pull thefroglab/frogstream
mkdir -p downloads torrents
sudo chown -R 1654:1654 downloads torrents # see "Volume permissions" below — do not skip
docker run -d --name frogstream \
-p 65525:65525 \
-v "$PWD/downloads:/data/downloads" \
-v "$PWD/torrents:/data/torrents" \
-v frogstream-config:/config \
--restart unless-stopped \
thefroglab/frogstream:0.3.3
Then drop files in and watch:
cp ~/Downloads/something.torrent ./torrents/
echo 'magnet:?xt=urn:btih:...' > ./torrents/something.magnet
docker logs -f frogstream
services:
frogstream:
image: thefroglab/frogstream:0.3.3
restart: unless-stopped
stop_grace_period: 15s
ports:
- "65525:65525/tcp"
volumes:
- ./downloads:/data/downloads # chown 1654:1654
- ./torrents:/data/torrents # chown 1654:1654 — drop .torrent/.magnet here
- frogstream-config:/config
logging: # the metrics line is ~36 MiB/day per torrent
driver: json-file
options: {max-size: "10m", max-file: "3"}
volumes:
frogstream-config:
FrogStream also ships as an app for Android and Windows. Each is a complete torrent client on its own — they don't need this container and this container doesn't need them. Pair them and the app becomes a remote for the server: its queue appears in the app, live, and you can add torrents, pause, resume and remove them from your phone or PC.
Get the apps: Android — Google Play · Windows — Microsoft Store
The link is direct and encrypted, device to device on your network. There is no account, no cloud relay, and nothing to open on your router.
services:
frogstream:
image: thefroglab/frogstream:0.3.3
restart: unless-stopped
network_mode: host # simplest — see "Bridge networking" below
environment:
DeviceLink__Enabled: "true"
DeviceLink__DeviceName: "home-server" # the name you'll see in the app
DeviceLink__AcceptPairingSecondsOnStart: "300" # 5-minute enrolment window on boot
DeviceLink__AutoPair: "true" # container confirms the code itself
volumes:
- ./downloads:/data/downloads
- ./torrents:/data/torrents
- frogstream-config:/config
volumes:
frogstream-config:
docker compose up -d, then docker compose logs frogstream should show
[link] accepting inbound pairing for 300s (config bootstrap). Pair within that window.
DeviceName).
Not listed — bridge network, VPN, another subnet? Type the server's address under
Connect by address (192.168.1.20, or 192.168.1.20:65526) and tap Connect.[link] pairing (incoming) from <your phone> · fingerprint 482196.
Check they match, then tap Codes match, confirm. With AutoPair on, the container confirms
its side on its own./config volume, so it
survives restarts and upgrades — and the enrolment window does not reopen once a device is
paired, which is what keeps restart: unless-stopped from leaving it permanently open.| In the app | What it does on the server |
|---|---|
| Devices → your server → Open queue | Live view of the container's transfers: progress, speed, peers, free disk space |
| Pause / Resume / Remove on a transfer | Acts on that download in the container. The header button pauses or resumes everything |
+ (Add torrent) → paste a magnet or pick a .torrent → under Download on, pick your server | The download runs in the container and lands in /data/downloads; the app shows it with the server's badge |
| Pull here on a finished transfer | Copies the completed files from the server to this phone or PC |
| Send in the server's queue | Sends files from this device to the server. They land under /config/received/ unless you set DeviceLink__ShareReceiveDirectory |
If the server goes offline, its queue stays visible as Read-only · device offline until it is back.
network_mode: host is the easy path. On a bridge, the multicast discovery that lists devices
automatically does not cross the bridge. Publish 65526/tcp and 65528/tcp, and pair with
Connect by address instead of picking it off the list. Everything after pairing works either way.AcceptPairingSecondsOnStart with AutoPair is trust-on-first-use. Anything that can reach the
control port during that window can pair. Use it on a network you trust and keep the window short.
Without AutoPair the container needs an attached console (-it) to accept confirm; AutoPair
is the headless path.The container runs as uid 1654 and never as root. A bind mount you create belongs to
you, so the container cannot write it and the process exits on startup with
UnauthorizedAccessException: Access to the path '/data/downloads' is denied.
sudo chown -R 1654:1654 ./downloads ./torrents
Named volumes (like frogstream-config above) get the right ownership automatically — this
only affects bind mounts. If you need the files owned by your own user, run the container as
yourself instead: --user "$(id -u):$(id -g)", with the directories owned to match.
Every setting is an environment variable. The defaults baked into the image are production defaults; you only need to set something to change it.
| Variable | Default | What |
|---|---|---|
Ingest__WatchDirectory | /data/torrents | Directory polled for .torrent / .magnet files |
Ingest__DownloadDirectory | /data/downloads | Where completed data lands |
Ingest__KeepSeeding | true | Keep seeding after a download completes |
Ingest__Sources__0 | — | Start a magnet or path at boot, no watch file needed |
Ingest__WatchPollInterval | 00:00:10 | How often the watch directory is scanned |
Torrent__ListeningPort | 65525 | Inbound peer port — publish and forward it |
Torrent__MaxConnectedPeerCount | 1000 | Peers kept connected, across all transfers |
Torrent__MaxPeersPerTorrent | 400 | Per-torrent sub-cap under the above (0 = none) |
Torrent__MaxConcurrentDials | 0 (= 2 × MaxConnectedPeerCount, so 2000 by default) | Connection attempts in flight, across all transfers. Above the peer cap on purpose — most dials never answer, so this is what sets discovery speed. Leave it at 0 unless you mean to pin it: lowering MaxConnectedPeerCount on a small host then lowers the socket count with it |
Torrent__MaxDownloadBytesPerSecond | 0 | Rate limit in bytes/sec, process-wide, 0 = unlimited |
Torrent__MaxUploadBytesPerSecond | 0 | Rate limit in bytes/sec, process-wide, 0 = unlimited |
Torrent__UploadSlots | 4 | Peers unchoked for upload at once |
Torrent__MetricsSampleInterval | 00:00:01 | How often the per-torrent stats line is emitted |
Logging__LogLevel__Default | Information | Set Warning to silence the per-second metrics line |
Logging__LogLevel__FrogStream.Metrics.Peers | (inherits) | Set Debug for one record per connected peer per sample, without putting the whole app in Debug |
Logging__LogLevel__FrogStream.Metrics.Files | (inherits) | Same, for per-file progress on a multi-file torrent |
Logging__File__Enabled | false | Opt-in rotating log file. stdout stays the primary path |
Logging__File__Directory | /logs | Mount your own directory here — see below |
Logging__File__LogLevel__Default | (inherits) | The file's own level, e.g. Debug to file while the console stays at Information |
Logging__File__MaxSizeBytes / __MaxFiles | 10485760 / 3 | Rotation threshold and how many files are kept |
Otel__Endpoint | (empty) | Set to an OTLP gRPC endpoint to export traces/metrics/logs |
DeviceLink__Enabled | false | Pair with the Android / Windows apps — see above |
DeviceLink__DeviceName | (hostname) | The name this container shows up as in the apps |
DeviceLink__AcceptPairingSecondsOnStart | 0 | Open a one-time pairing window on boot, in seconds |
DeviceLink__AutoPair | false | Auto-confirm pairing requests during that window |
DeviceLink__ShareReceiveDirectory | (derived) /config/received | Where files sent from a paired app land |
The container logs to stdout, which is what docker logs, docker compose logs and every logging
driver read. That is the primary path and needs no configuration.
Two optional extras:
# One record per connected peer per sample, without putting the whole app into Debug
docker run ... -e 'Logging__LogLevel__FrogStream.Metrics.Peers=Debug' thefroglab/frogstream
# Keep log files after the container is gone — mount your own directory at /logs
sudo chown -R 1654:1654 ./logs
docker run ... -e Logging__File__Enabled=true -v "$PWD/logs:/logs" thefroglab/frogstream
/logs is a mount point of its own, separate from /config, so you can put logs on a different disk or
point a log shipper at them without touching the state volume. If you enable the file sink and don't
mount anything there, the files go into the container's writable layer and disappear when the container is
removed — the size cap still applies, but you get nothing back after a docker rm.
| Port | Purpose |
|---|---|
65525/tcp | Inbound peers. Publish and forward this, or you can download but barely seed. |
65526/tcp, 65528/tcp | DeviceLink control / file transfer. Only when DeviceLink__Enabled=true. |
UPnP and NAT-PMP cannot reach your router from a bridged container, so the port has to be
forwarded on the router by hand. network_mode: host avoids that, on Linux hosts.
| Tag | Meaning |
|---|---|
0.3.3 | Immutable release. Pin this in production. Built on .NET 11 RC1. |
0.3.2 | Previous release (.NET 11 RC1). Still available, no longer updated. |
0.3.1 | Previous release (.NET 11 RC1). Still available, no longer updated. |
0.3.0 | Previous release (.NET 11 RC1). Still available, no longer updated. |
0.2.0 | Previous release (.NET 10). Still available, no longer updated. |
0.1.0 | Older release. Still available, no longer updated. |
latest | Moves with every release. Convenient, not reproducible. |
linux/amd64 only today. arm64 is planned.
HEALTHCHECK. The image has no shell to exec one and the app exposes no HTTP surface.
Watch docker logs instead.docker exec into it will not work — that is the point.
It is a chiseled image with 119 files and no executables outside /app.--read-only --tmpfs /tmp, --cap-drop=ALL and
--security-opt=no-new-privileges.--memory=64m.FrogStream is proprietary software, free to run and redistribute as an unmodified image.
See /app/LICENSE in the image.
It transfers whatever data you point it at. It hosts nothing, indexes nothing, and ships with no content or trackers. You are responsible for the legality of what you transfer.
Third-party components and their licences: /app/THIRD-PARTY-NOTICES.md, plus per-package
copyright files under /usr/share/doc/ inside the image.
Licence questions: [email protected]. Security disclosures: [email protected].
Content type
Image
Digest
sha256:6706a1646…
Size
14.8 MB
Last updated
about 7 hours ago
docker pull thefroglab/frogstream