A minimal, up-to-date Tor Docker image built on Alpine.
Exposes a SOCKS5 proxy (port 9150) and a control port (port 9151) so Python's
stem library can rotate circuits programmatically.
| Image | Tor version | Control port | Last updated |
|---|---|---|---|
dperson/torproxy |
old | manual torrc only | ~5 years ago |
barneybuffet/tor |
old | yes (buggy hash) | ~4 years ago |
peterdavehello/tor-socks-proxy |
recent | ❌ no | active |
This image:
- Installs Tor directly from Alpine's community repo — always the latest available version.
- Hashes
CONTROL_PASSWORDat runtime so no stale hash is ever baked in. - Supports multi-arch builds (
linux/amd64,linux/arm64). - Rebuilds automatically every week to pick up Tor/Alpine updates.
tor-proxy/
├── .github/
│ └── workflows/
│ └── docker-publish.yml # build + push to Docker Hub & GHCR
├── docker/
│ ├── Dockerfile # Alpine + tor + su-exec
│ └── entrypoint.sh # hashes password at runtime, writes torrc
├── example/
│ └── tor_client.py # httpx + stem circuit-rotation example
├── .env.example # template for local .env
├── compose.yml
├── LICENSE
└── README.md
docker compose pull
docker compose up -ddocker compose up -d --builddocker run -d \
--name tor-proxy \
--restart unless-stopped \
-e CONTROL_PASSWORD=changeme \
-p 127.0.0.1:9150:9150 \
-p 127.0.0.1:9151:9151 \
-v tor_data:/var/lib/tor \
sdipu/tor-proxy:latestTo persist state across restarts (faster re-bootstrap), the -v tor_data:/var/lib/tor
volume is optional but recommended. Omit it if you don't need it.
docker compose logs -f
# once you see "Bootstrapped 100%":
curl --socks5-hostname 127.0.0.1:9150 https://httpbin.org/ipAll settings are environment variables. Copy .env.example to .env and edit:
| Variable | Default | Description |
|---|---|---|
CONTROL_PASSWORD |
changeme |
Plain-text password — hashed at startup |
SOCKS_HOST |
0.0.0.0 |
SOCKS5 bind address inside container |
CONTROL_HOST |
0.0.0.0 |
Control port bind address |
SOCKS_POLICY |
RFC1918 ranges + loopback | Comma-separated accept IP/CIDR rules |
The hashed password is generated each time the container starts via:
tor --hash-password "${CONTROL_PASSWORD}"This avoids the Bad HashedControlPassword: wrong length or bad encoding error
that plagues older images that bake a stale hash into the config.
Install dependencies:
pip install "httpx[socks]" stemSet env vars if not using defaults:
export TOR_CONTROL_PASSWORD=changemeRun:
python example/tor_client.pyExpected output:
2026-06-08 12:00:01 INFO [1] Exit IP: 185.220.101.5
2026-06-08 12:00:01 INFO [1] https://httpbin.org/ip → 200
2026-06-08 12:00:11 INFO New Tor circuit requested.
2026-06-08 12:00:11 INFO [2] Exit IP: 178.175.128.50 ← different IP
...
your script
│
├─ HTTP request ──► SOCKS5 port 9150 ──► Tor network ──► exit node ──► target
│
└─ stem NEWNYM ──► Control port 9151 ──► Tor picks a new 3-hop circuit
stem sends SIGNAL NEWNYM to the control port. Tor enforces a ~10 s cooldown
between rotations. The get_newnym_wait() call in tor_client.py respects this
automatically so you always get a genuinely new exit node.
| Port | Protocol | Purpose |
|---|---|---|
| 9150 | TCP | SOCKS5 proxy (connect your app) |
| 9151 | TCP | Control port (stem / NEWNYM) |
Both ports are bound to 127.0.0.1 on the host by default (see compose.yml).
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag sdipu/tor-proxy:latest \
--push \
./dockerCI (.github/workflows/docker-publish.yml) does this automatically on every push
to main and on v* tags.
The weekly scheduled CI build handles this automatically. To rebuild manually:
docker compose build --no-cache
docker compose up -d- The control port is bound only to
127.0.0.1on the host (seecompose.yml). Never expose port 9151 publicly. - Store
CONTROL_PASSWORDin a.envfile (excluded from version control) or use Docker secrets in production. - The SOCKS proxy accepts only RFC1918 addresses by default. Adjust
SOCKS_POLICYif your app container is on a different subnet. - The image runs Tor as the
toruser (created by the Alpine package) — not root.