Minimal, hardened Tor middle relay - no exit, no bridge
830
A minimal, hardened Docker image for running a pure Tor middle relay — no exit, no bridge, nothing else — with a small, read-only web dashboard. Built on the official, signed Tor Project Debian packages and running as unprivileged, non-root users.
Image: r600/tor-middle-relay
ExitRelay 0, ExitPolicy reject *:*,
IPv6Exit 0, BridgeRelay 0, SocksPort 0. This node only forwards
encrypted traffic between other Tor relays. It never originates exit
traffic and is not a bridge. There is no setting to change this — see
Why this can never become an exit.127.0.0.1:8080.deb.torproject.org, GPG-verified. No
self-compiled binaries.debian-tor), Sandbox 1, SafeLogging 1,
DisableDebuggerAttachment 1, NoExec 1, read-only root filesystem,
all Linux capabilities dropped except CHOWN/SETUID/SETGID (needed
once at start to drop privileges), no-new-privileges. The dashboard
runs as a separate user that cannot read the relay's keys.linux/amd64 and linux/arm64.git clone https://github.com/syncip/tor-middle-node.git
cd tor-middle-node
cp .env.example .env
# edit .env: set NICKNAME and CONTACT_INFO
docker compose up -d
docker compose logs -f
Open the dashboard at http://127.0.0.1:8080 on the server — or, from your own machine, through an SSH tunnel:
ssh -L 8080:127.0.0.1:8080 your-server
# then open http://127.0.0.1:8080
Watch the logs for Self-testing indicates your ORPort ... is reachable.
It can take a few hours before the relay shows up in the public Tor
consensus, and several days before it carries meaningful traffic.
docker-compose.yml:
services:
tor-middle-relay:
image: r600/tor-middle-relay:latest
container_name: tor-middle-relay
restart: unless-stopped
env_file:
- .env
ports:
- "9001:9001"
# Dashboard: bound to localhost only. Reach it from another machine
# through an SSH tunnel: ssh -L 8080:127.0.0.1:8080 your-server
- "127.0.0.1:8080:8080"
volumes:
- tor-data:/var/lib/tor
- dashboard-data:/var/lib/tor-dashboard
# Everything is dropped except what the entrypoint needs to hand the
# data directories to the service users and to drop privileges.
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
- /etc/tor
- /run/tor
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
tor-data:
dashboard-data:
.env:
NICKNAME=MyMiddleRelay
CONTACT_INFO=Your Name <[email protected]>
OR_PORT=9001
Everything else has sensible defaults — see .env.example and the
Configuration table.
docker compose up -d
The gluetun container owns the network stack and the relay joins it with
network_mode: "service:gluetun". The relay therefore has no ports:
section of its own — the ORPort is published on the gluetun service.
Your VPN provider must support port forwarding, and the forwarded port has to reach the relay's ORPort. A relay whose ORPort is unreachable from the outside will never be included in the consensus. Also note that many VPN providers prohibit running Tor relays over their service — check their terms first.
docker-compose.gluetun.yml:
services:
gluetun:
image: qmcgaw/gluetun:latest
container_name: tor-relay-gluetun
restart: unless-stopped
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
env_file:
- .env.gluetun
ports:
# Published here, not on the tor service, because the relay shares
# this container's network namespace.
- "9001:9001"
# Dashboard of the relay, localhost only (see docker-compose.yml)
- "127.0.0.1:8080:8080"
security_opt:
- no-new-privileges:true
# No healthcheck override: gluetun ships its own HEALTHCHECK, which
# checks the tunnel without calling a third-party IP lookup service.
tor-middle-relay:
image: r600/tor-middle-relay:latest
container_name: tor-middle-relay
restart: unless-stopped
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
env_file:
- .env
volumes:
- tor-data:/var/lib/tor
- dashboard-data:/var/lib/tor-dashboard
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
- /etc/tor
- /run/tor
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
tor-data:
dashboard-data:
.env.gluetun (WireGuard example — see the
gluetun wiki for your provider):
VPN_SERVICE_PROVIDER=your_provider
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY=your_private_key
WIREGUARD_ADDRESSES=10.0.0.2/32
SERVER_COUNTRIES=Netherlands
# Let inbound ORPort connections through the tunnel
FIREWALL_INPUT_PORTS=9001
In .env, set RELAY_ADDRESS to the VPN's public IP if Tor's
auto-detection picks the wrong address:
RELAY_ADDRESS=203.0.113.10
cp .env.example .env
cp .env.gluetun.example .env.gluetun
docker compose -f docker-compose.gluetun.yml up -d
| Variable | Description | Default |
|---|---|---|
NICKNAME | Relay name, 1-19 letters/digits | (required) |
CONTACT_INFO | Contact details so Tor admins can reach you | (recommended) |
OR_PORT | Port for relay-to-relay traffic | 9001 |
OR_IPV6 | ORPort on IPv6: auto (if a global IPv6 address exists), 1, 0 | auto |
RELAY_ADDRESS | Public IP; only set if auto-detection fails | (empty = auto) |
DIR_CACHE | Act as a directory cache (0/1) | 1 |
MYFAMILY | Comma-separated fingerprints of your other relays | (empty) |
BANDWIDTH_RATE | Sustained rate, e.g. 5 MBytes | (empty = unlimited) |
BANDWIDTH_BURST | Burst rate, e.g. 10 MBytes | (empty) |
MAX_ADVERTISED_BANDWIDTH | Bandwidth advertised in the directory | (empty) |
ACCOUNTING_MAX | Total data volume cap, e.g. 500 GBytes | (empty = no cap) |
ACCOUNTING_START | Accounting period | month 1 00:00 |
DASHBOARD_ENABLED | Run the dashboard (0/1) | 1 |
DASHBOARD_PORT | Dashboard port inside the container (change the ports: mapping too) | 8080 |
DASHBOARD_PASSWORD | HTTP basic auth password (any username) | (empty = none) |
SANDBOX | Tor's seccomp sandbox (0/1) — only disable if Tor fails with a sandbox error | 1 |
All values are validated at start (strict patterns, no line breaks); an invalid value stops the container with a clear error instead of producing a half-broken torrc. To check a configuration without starting the relay:
docker compose run --rm tor-middle-relay check
On bandwidth: Tor applies a single rate to both directions —
RelayBandwidthRate is not a separate download/upload limit. If you need
asymmetric shaping, do it on the host or router (e.g. tc), not in the
container.
Exit traffic is not a setting in this image. Four independent layers make sure of it:
ExitPolicy accept ... line into the torrc. Only the listed
variables are substituted into the template.ExitRelay 0,
ExitPolicy reject *:*, IPv6Exit 0, BridgeRelay 0, SocksPort 0)
is the last block of the rendered torrc.ExitPolicy replaces
the whole list, so even a tampered torrc cannot open an exit.--dump-config) and
refuses to start unless it confirms all of the above. While running,
the dashboard checks the effective exit policy every few seconds and
halts Tor immediately if it ever accepts anything. The dashboard itself
can only send read-only commands to Tor.The CI workflow runs these checks (including injection attempts) against every image before it is pushed.
A small web page served from inside the container (Python standard library, no external scripts, no CDN):
dashboard-data volume, so they survive
restarts and image updates.It talks to Tor through a Unix control socket on a tmpfs (/run/tor), not
a TCP port, and runs as its own user (tordash) that can reach that
socket but not the relay's identity keys. It is read-only: no button,
form or API can change the relay.
The compose files publish it on 127.0.0.1 only. If you publish it on a
public interface anyway, set DASHBOARD_PASSWORD and put it behind a
TLS reverse proxy. Set DASHBOARD_ENABLED=0 to turn it off completely
(the control socket is then not created either).
The Tor version is baked into the image at build time — the container does
not update itself, and restarting it changes nothing. This matters for a
relay: the directory authorities reject end-of-life Tor versions, so a relay
left on an old version will eventually be dropped from the consensus. Tor
warns about this in the logs (Please upgrade! This version of Tor is obsolete...) well before it happens.
Two things keep this current:
1. The image gets rebuilt weekly. The workflow has a schedule trigger
that rebuilds every Monday, picking up new Tor releases and Debian security
updates without needing a commit. Scheduled runs set no-cache: true — this
is deliberate and important: with the layer cache active, the
apt-get install tor layer would be reused and the "rebuild" would ship the
exact same old version. You can also trigger this manually via
Actions → Build and Publish Docker Image → Run workflow, optionally
ticking "Build without cache".
2. Your host has to pull the new image. A rebuilt image on Docker Hub doesn't reach your server on its own:
docker compose pull
docker compose up -d
To automate it, add Watchtower:
watchtower:
image: nickfedor/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --cleanup --schedule "0 0 5 * * *" tor-middle-relay
Note: this is the actively maintained fork. The original containrrr/watchtower
was archived in December 2025 and its pinned Docker API client is rejected by
current Docker engines, so it no longer works. The fork is a drop-in
replacement — same flags, same labels, only the image name changes.
Mounting the Docker socket gives that container full control over your Docker
daemon, which is effectively root on the host. If you'd rather not, a cron job
running docker compose pull && docker compose up -d does the same job with
less exposure.
Recreating the container is safe — the relay's identity keys live on the
tor-data volume, so the fingerprint and accumulated reputation survive the
update. Expect a short dip in traffic while the relay reconnects.
Check which version is running:
docker compose logs tor-middle-relay | grep "Tor version"
Or inspect an image without starting it:
docker run --rm --entrypoint cat r600/tor-middle-relay:latest /etc/tor-version
.github/workflows/docker-publish.yml builds for amd64 and arm64 and
pushes to r600/tor-middle-relay on every push to main and on v*.*.*
tags. Add two repository secrets under
Settings → Secrets and variables → Actions:
DOCKERHUB_USERNAME — your Docker Hub usernameDOCKERHUB_TOKEN — a Docker Hub access token (Docker Hub → Account
Settings → Personal access tokens), not your passwordThe workflow also pushes this README to the Docker Hub repository page — that's a separate API call, which is why an image push alone leaves the Docker Hub overview empty.
That step needs a token with read/write/delete scope. A plain push token (read/write) gets a 403 from the description API. Two options:
DOCKERHUB_TOKEN read/write/delete scope, orDOCKERHUB_DESCRIPTION_TOKEN, with read/write/delete — the workflow uses
it when present and falls back to DOCKERHUB_TOKEN otherwiseThe step is marked continue-on-error, so a missing or under-scoped token
logs a warning instead of failing the build. Docker Hub truncates the README
at 25,000 bytes and the short description at 100.
Tagging a release also publishes versioned tags:
git tag v1.0.0
git push origin v1.0.0
/var/lib/tor. It holds the relay's private identity keys.
Losing them means starting over from zero reputation.127.0.0.1 only.Guard flag. It is then also used
as the first hop of circuits, so Tor users connect to it directly. That
is normal and still not an exit — but it means your relay sees client
IP addresses (never their destinations). SafeLogging 1 keeps them out
of the logs, and the dashboard never shows peer addresses..
├── Dockerfile
├── torrc.template
├── entrypoint.sh
├── dashboard/
│ ├── server.py
│ └── static/ (index.html, app.js, style.css)
├── docker-compose.yml
├── docker-compose.gluetun.yml
├── .env.example
├── .env.gluetun.example
├── .gitignore
├── .dockerignore
└── .github/workflows/docker-publish.yml
MIT
Content type
Image
Digest
sha256:ce4db6bfa…
Size
52.2 MB
Last updated
1 day ago
docker pull r600/tor-middle-relay