A Docker Compose stack that runs a Freenet node as a pure contributing peer to the Freenet network. It's built and tested for Unraid, but the compose file itself has no Unraid-specific dependencies and should run on any Docker host.
This node only contributes storage and bandwidth to the network — it is
never configured as a gateway (is_gateway is never set to true), so it
never acts as a bootstrap/entry point for other clients. network_mode: host
is required for Freenet's own UDP NAT traversal (hole-punching) to have a
chance of working at all.
In practice, hole-punching alone is not always enough. It only works
reliably against a "cone" NAT — one that keeps the same external port for a
given internal port regardless of destination. Many consumer/prosumer
routers (confirmed with OPNsense, likely others) randomize the external port
per outbound connection instead, which silently breaks hole-punching: the
node's dashboard will show NAT hole punching: 0/N successful and only ever
connect to gateways, never to real peers. If that happens, see
Troubleshooting: NAT hole-punching fails
below — it does require a port forward plus a matching outbound NAT rule,
which contradicts the "no port forwarding needed" assumption this project
started with.
Note: This is the new Rust-based Freenet (freenet.org). The older, unrelated anonymous network that used to be called "Freenet" is now called Hyphanet — a completely different project.
- A Docker host with Docker and the
docker composeplugin installed. - A host directory (or, on Unraid, a share) for the node's datastore —
config, keys, contracts, and database all live here. Make sure it has
enough free space for the storage budget you configure (see
MAX_HOSTING_STORAGE_GBbelow); the default is 2500 GB.
-
Copy the environment template and adjust it for your setup:
cp .env.example .env
At minimum, review:
ALLOWED_CIDR— extra allowed source ranges for the dashboard/API. Freenet already allows every private/RFC1918, loopback and link-local source address regardless of what you put here, so this value does not restrict anything — it only adds ranges beyond that default. A plain LAN value like10.10.1.0/24is therefore effectively a no-op, and you cannot lock yourself out by getting it "wrong". It matters only when you reach the dashboard from a range that isn't RFC1918 — most commonly Tailscale's CGNAT range100.64.0.0/10. Separate multiple ranges with commas:ALLOWED_CIDR=10.10.1.0/24,100.64.0.0/10.DATA_PATH— the host directory/share where the node's data will live.MAX_HOSTING_STORAGE_GBandTOTAL_BANDWIDTH_LIMIT_MBPS— see the table below.
-
Run the deploy script:
./deploy.sh
This checks that
WS_API_PORT(default7509) isn't already in use on the host, then pulls the prebuilt image forIMAGE_TAGfrom GitHub Container Registry (ghcr.io/raybomb87/freenet-unraid) and starts the container — no local build, no Rust toolchain required. That image is itself a thin wrapper: it downloads and checksum-verifies the official prebuiltfreenet-corebinary for the pinned release rather than compiling from source (seedocker/Dockerfile). -
Confirm the container is running:
docker logs -f freenet-node
| Variable | Default | Meaning |
|---|---|---|
IMAGE_TAG |
v0.2.111 |
Which git tag of freenet-core gets built. See "Updating" below. |
DATA_PATH |
/mnt/user/FreeNET |
Host path mounted into the container as the node's data directory (config, keys, contracts, database). |
MAX_HOSTING_STORAGE_GB |
2500 |
How much disk space (in GB) the node is allowed to use hosting other peers' contract data for the network. Passed as both --max-hosting-storage and --max-hosting-disk (see the note below the table). |
ALLOWED_CIDR |
10.10.1.0/24 |
Extra allowed source ranges for the dashboard/API. Private/RFC1918, loopback and link-local addresses are always allowed regardless, so this restricts nothing — it only adds ranges beyond that (e.g. Tailscale's 100.64.0.0/10). Comma-separated. |
WS_API_PORT |
7509 |
The port the dashboard/WebSocket API listens on. |
TOTAL_BANDWIDTH_LIMIT_MBPS |
100 |
A total bandwidth budget (Mbps) that is divided across active connections, not a hard ceiling on all traffic. See "Bandwidth" below. |
These values are passed through to the freenet binary as CLI flags by
entrypoint.sh (--max-hosting-storage, --max-hosting-disk,
--total-bandwidth-limit, --allowed-source-cidrs, --ws-api-port) — there's
no config.toml to edit by hand. The storage and bandwidth values are
converted from GB/Mbps to bytes/bytes-per-second by entrypoint.sh before
being passed in.
Note on the storage budget: --max-hosting-storage on its own is not
enough. The binary applies a second, independent ceiling,
--max-hosting-disk, which defaults to just 32 GiB. The effective budget
is the lower of the two, so without setting both, a 2500 GB budget would be
silently clamped to 32 GiB. entrypoint.sh therefore passes the same byte
value to both flags.
The bandwidth limits in freenet-core are weaker than their names suggest,
which is worth knowing before you rely on them:
total_bandwidth_limit(set viaTOTAL_BANDWIDTH_LIMIT_MBPS) is a budget that is divided across active connections — the binary's own help text describes per-connection rates as being computed astotal / active_connections. It is not a single hard ceiling on all the traffic the node generates.bandwidth_limitapplies only to thesend_streammechanism for large data transfers. The general packet rate limiter infreenet-coreis disabled upstream as ofv0.2.106— quotingfreenet network --helpdirectly: "The general packet rate limiter is currently disabled due to reliability issues."
The practical consequence is that real-world usage can exceed the nominal limit, particularly with many concurrent connections.
Required verification step: watch your router's (or the host OS's) own
traffic graph for a full day after first startup and confirm that actual usage
is acceptable. This is not optional — Freenet's own self-reported numbers are
not sufficient evidence that the cap is being honoured. Lower
TOTAL_BANDWIDTH_LIMIT_MBPS if usage is higher than you want.
Freenet's own dashboard/WebSocket API (the port set by WS_API_PORT, default
7509) is described in Freenet's own documentation as a fully privileged
API — it should be treated like SSH access. Anything that can connect to
this API effectively has full control over the node.
Because of that:
- The real protection here is that there is no route in to the port from
the internet — it is not
ALLOWED_CIDRthat provides it. Freenet lets every private/RFC1918 address through regardless, so anything on your LAN can reach the API. Treat the whole LAN as trusted, or segment your network if that isn't acceptable. - Never port-forward this port, and never expose it to the open internet.
- If you need remote access, route it over a trusted, authenticated network layer you already control — a VPN, Tailscale, or an SSH tunnel — never by exposing the port directly.
With that in place, you reach the dashboard at:
http://<your-docker-host-LAN-IP>:7509
— either directly from a device on your LAN, or over your VPN/Tailscale/SSH tunnel from elsewhere. Never over the open internet.
freenet-core defaults to sending operational telemetry (timing/topology
data, not contract content) to nova.locut.us
(telemetry-enabled = true upstream). A home contributing node has no real
need for this, so entrypoint.sh sets FREENET_TELEMETRY_ENABLED=false by
default.
One quirk worth knowing: --telemetry-enabled is a bare boolean CLI switch
that can only turn telemetry on (--telemetry-enabled false errors out,
because clap parses false as a different positional argument instead).
The only way to turn it back off is the FREENET_TELEMETRY_ENABLED
environment variable, which the binary's argument parsing does honor for
true/false. If you want to re-enable telemetry, set
FREENET_TELEMETRY_ENABLED=true as a container environment variable — there
is no corresponding switch in .env at the moment.
freenet-core ships new releases frequently, so this stack always pins to
one specific git tag via IMAGE_TAG in .env (v0.2.111 at the time of
writing) — the image is never built against main.
To update to a new version:
./update.sh v0.2.107This updates IMAGE_TAG in .env to the new tag and re-runs deploy.sh,
which pulls the matching image tag and restarts the container.
Note for forks / other maintainers: ghcr.io/raybomb87/freenet-unraid
images are published by running the repo's "Build and publish Docker image"
GitHub Action (.github/workflows/docker-publish.yml) by hand for a given
freenet-core tag — they are not built automatically on every upstream
release. If you need a version that hasn't been published yet, either wait
for the maintainer to run it or run the workflow yourself in your own fork
(it will publish to ghcr.io/<your-username>/freenet-unraid instead — update
the image: line in docker-compose.yml to match).
entrypoint.sh drives the node entirely through CLI flags
(--max-hosting-storage, --max-hosting-disk, --total-bandwidth-limit,
--allowed-source-cidrs, --ws-api-port) rather than a templated
config.toml. Those flag names, their defaults, and the config schema they
write out were verified against a real running v0.2.106 binary rather than
taken from documentation — the results are recorded in
docker/CONFIG-KEYS-VERIFIED.md.
Two things are worth knowing if you bump IMAGE_TAG:
- The binary's own config keys are an inconsistent mix of
snake_caseandkebab-case(is_gatewaywith underscores,max-hosting-storagewith hyphens). That's upstream behaviour, not a quirk of this repo. - Because
freenet-corereleases frequently, flag names and their defaults can change between releases. Checkfreenet network --helpagainst the new tag before moving much pastv0.2.106, and update bothentrypoint.shanddocker/CONFIG-KEYS-VERIFIED.mdif anything has shifted. A silently renamed or re-defaulted flag is the most likely way this stack breaks on an upgrade —--max-hosting-diskis a live example of a default (32 GiB) that will quietly cap your storage budget if it stops being passed.
Check the dashboard (http://<host-LAN-IP>:7509) after the node has been up
for a few minutes. If it shows something like:
Only connected to gateways — no peer-to-peer connections yet
NAT hole punching: 0/48 successful
...your router's NAT is very likely not a "cone" NAT — it assigns a different external port per outbound connection (sometimes called "symmetric" or address/port-dependent mapping) instead of keeping one stable external port for the node's internal port. Hole-punching depends on the external port your node advertises to the network staying the same for every peer, so this silently breaks it: the node only ever reaches the gateways it connects outbound to, never real peers.
There is no software fix for this — it has to be corrected on the router. Verified working on OPNsense 26.1 (the same fix applies conceptually to any router with similar automatic outbound NAT/PAT):
- Inbound port forward (OPNsense: Firewall → NAT → Destination NAT —
older pfSense-derived UIs call this "Port Forward"): WAN, UDP, destination
port = your node's
network-port(find it in the node'snode-config/config.toml, or watch what the dashboard's "Firewall likely blocking..." warning names) → redirect to<host-LAN-IP>on the same port. - Outbound NAT rule with Static Port: WAN, source = your LAN subnet, source port = the same node port → translate to the interface address, with Static Port enabled. Without this, the port forward alone isn't enough — the node's outbound packets to new peers would still get a randomized external port that doesn't match what it advertised, so genuine peers still can't punch back through.
On OPNsense specifically, if outbound NAT mode is "Hybrid," a manual rule for
the same interface can still lose to the broader automatic rule: pf
evaluates NAT rules top-to-bottom and the last matching rule wins, and
automatic Hybrid-mode rules are compiled in after manual ones for the same
interface regardless of the order shown in the "Manual rules" GUI table. If
your manual static-port rule doesn't seem to take effect
(pfctl -sn -vv shows Evaluations but States: 0 for it), switching
Outbound NAT to full "Manual" mode — which lets you place your rule after the
general one — is the reliable fix; there isn't a lighter-weight option within
Hybrid mode.
After both rules are in place, kill any existing state for the node's own
host so new connections pick up the fresh mapping
(pfctl -k <your-host-LAN-IP> on OPNsense/pfSense), or just restart the
container. Give it a few minutes — the dashboard's hole-punching ratio and
peer list fill in gradually as new connections are attempted.
This stack is a thin Docker wrapper around
freenet/freenet-core — all the
actual node logic is theirs. Because that project ships frequent releases,
this repo builds from a pinned upstream tag (IMAGE_TAG) rather than
tracking main, so upgrades are a deliberate, tested step rather than an
automatic moving target.
MIT — see LICENSE.