Rotating Tor HTTP proxy in Kubernetes
Every proxy article eventually ends with someone asking: "but can I rotate IPs automatically?" The answer running on this server is zhaow-de/rotating-tor-http-proxy — a container that runs N parallel Tor circuits behind a single HAProxy frontend. Each request goes to a different circuit, so you get a different exit IP without doing anything special on the client side.
What runs inside the container
The image bundles:
- N pairs of Privoxy + Tor (configurable via
TOR_INSTANCES) - HAProxy in front, round-robining across all Privoxy instances
- A cron job that sends
NEWNYMto each Tor control socket everyTOR_REBUILD_INTERVALseconds
Each Tor process maintains its own circuit independently. When HAProxy routes a request to instance #3, that request exits via instance #3's current exit node. The next request might go to instance #1 with a completely different exit node.
The stats UI at :30444 shows per-backend session counts and traffic, which is what the proxy page pulls to show the "Tor stats" table.
Kubernetes setup
Everything lives in a dedicated namespace tor. The deployment is simple — one pod, Recreate strategy (no benefit to rolling update here), emptyDir for /var/lib/tor so Tor can write its state files:
yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: tor-proxy
namespace: tor
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: tor-proxy
template:
metadata:
labels:
app: tor-proxy
spec:
containers:
- name: tor-proxy
image: zhaowde/rotating-tor-http-proxy:latest
env:
- name: TOR_INSTANCES
value: "5"
- name: TOR_REBUILD_INTERVAL
value: "1800"
ports:
- containerPort: 3128 # HAProxy HTTP
- containerPort: 4444 # HAProxy stats
volumeMounts:
- name: tor-data
mountPath: /var/lib/tor
volumes:
- name: tor-data
emptyDir: {}
Two NodePort services expose it outside the cluster: 30128 for the proxy and 30444 for the HAProxy stats UI.
No auth
There is intentionally no authentication on the Tor proxy. The exit IPs are Tor exit nodes — they're already public, already logged by every major CDN, and rotating every 30 minutes anyway. Adding a password would only be security theater. The NodePort is exposed on the VPS public IP, so technically anyone could use it, but practically nobody knows the port.
If this were a shared infrastructure, I'd add a Proxy-Authorization header check in HAProxy. For a personal setup with 5 circuits and a 1800s rebuild interval, it doesn't matter.
Latency
Tor is slow. A direct curl https://ifconfig.me from the VPS takes ~50ms. Through the Tor proxy it's typically 2–8 seconds depending on which exit node is in the circuit. This is fine for tasks where you need a different IP, not for streaming.
bash# verify rotation — each call should return a different IP
for i in 1 2 3 4 5; do
curl -s --proxy http://91.184.248.13:30128 https://ifconfig.me
echo
done
On a good day you'll see 4–5 distinct IPs across 5 requests.
HAProxy stats integration
The stats endpoint at :30444/haproxy?stats;csv;norefresh returns a CSV with one row per backend. The proxy page's /stats/tor endpoint fetches this, parses the CSV, and returns JSON:
json{
"ok": true,
"backends": [
{"name": "tor1", "status": "UP", "scur": 0, "stot": 12, "bin": 4096, "bout": 8192},
...
],
"total_stot": 60,
"total_bin": 20480,
"total_bout": 40960
}
The frontend polls this every 60 seconds and renders a table showing each circuit's status and cumulative traffic.
Circuit rebuild vs NEWNYM
There are two different things that "rotate" circuits here:
- NEWNYM signal (every 10 minutes by default) — tells Tor to build a new circuit for future streams. Fast, but the old circuit may persist for open connections.
- Full container restart (
TOR_REBUILD_INTERVAL=1800) — kills all Tor processes and restarts them. This guarantees fresh circuits but causes ~60 seconds of downtime while Tor builds initial circuits and joins the network.
The TOR_REBUILD_INTERVAL in the Kubernetes deployment is 1800 (30 minutes). For most use cases NEWNYM is sufficient; the full rebuild is more of a "just in case the circuit is stuck" mechanism.
Setup script
The k0s-setup/21-setup-tor-proxy.sh script applies the manifest, waits for the pod to be Running, then sleeps 60 seconds for Tor to build initial circuits before running a 3-request validation. The sleep is important — the proxy accepts connections immediately but returns errors until Tor is actually connected to the network.
bashssh root@server 'bash -s' < k0s-setup/21-setup-tor-proxy.sh
It prints the exit IP for each of the 3 test requests. If you see 3 different IPs, the rotation is working.
Troubleshooting
Proxy returns connection errors immediately after startup:
Tor needs 30–90 seconds to bootstrap circuits after container start. The liveness probe should be delayed accordingly:
yamllivenessProbe:
httpGet:
path: /haproxy?stats;csv;norefresh
port: 4444
initialDelaySeconds: 90
periodSeconds: 30
All 5 requests return the same IP:
HAProxy is probably not round-robining correctly or all circuits share the same exit. Try increasing TOR_INSTANCES to 10 and verify stats at port 30444.
Very slow or 100% failure:
Some countries block Tor. Check if your VPS provider is in a region with restrictions. Use tor --verify-config inside the pod to check for errors.
bashkubectl exec -n tor deploy/tor-proxy -- tor --verify-config
kubectl logs -n tor deploy/tor-proxy | grep -i error
Resource limits
Tor is CPU-light but memory-hungry. Each circuit uses ~50MB RAM. With 5 instances:
yamlresources:
requests:
memory: "256Mi"
cpu: "50m"
limits:
memory: "512Mi"
cpu: "200m"
On a 2GB VPS, 5 instances is a safe number. For 10 instances consider bumping to 1GB limit.
Summary
| Component | Purpose |
|---|---|
| N Tor processes | Each holds independent circuit |
| HAProxy | Round-robin across all Tor circuits |
| NEWNYM signal | Refresh circuits without restart |
| TOR_REBUILD_INTERVAL | Force full circuit rebuild |
| NodePort 30128 | HTTP proxy access |
| NodePort 30444 | HAProxy stats UI |