Tor Hidden Service Gateway
Expose any clearnet website as a Tor .onion hidden service. Uses Nginx sub_filter to rewrite clearnet URLs to .onion in response bodies — the origin application requires zero modifications.
Tor visitor → Tor network → tor container → onion-proxy (nginx sub_filter)
↓
your origin server
- tor — Alpine container running the
tordaemon, one.onionaddress per site - onion-proxy — Nginx container with one server block per site. Forwards requests to your origin with the clearnet
Hostheader, then rewrites all clearnet URLs to.onionin the response body
The core trick is two-fold:
proxy_set_header Host domain.com— your origin sees the clearnet hostname and generates its normal URLssub_filter— Nginx does a string replacement on the response body, convertinghttps://domain.com→http://your-onion-address.onionbefore it reaches the Tor visitor
This means the origin application (WordPress, Ghost, a static site, anything) has no idea Tor exists. No plugins, no config changes, no cache-busting.
git clone https://github.com/unredacted/tor-onion.git
cd tor-onion
# Add a site — generates nginx config + torrc entry
./add-site.sh mysite example.com
# Start everything
docker compose up -d
# Wait ~30s for Tor to bootstrap, then grab your .onion address
docker compose exec tor cat /var/lib/tor/hidden_service/mysite/hostnameVisit the .onion address in Tor Browser — all links and assets should point to the .onion, not the clearnet.
The --origin flag controls where the onion-proxy forwards requests. There are three common patterns:
Your origin is a reverse proxy (Traefik, Caddy, Nginx, HAProxy, etc.) running on the same machine, listening on the host's port 443.
./add-site.sh mysite example.com
# Equivalent to:
# ./add-site.sh mysite example.com --origin https://host.docker.internal:443The host.docker.internal address lets containers reach the host network. The extra_hosts directive in docker-compose.yml enables this on Linux.
Your origin is another container (e.g., a WordPress or Node.js app).
./add-site.sh mysite example.com --origin http://wordpress:80You'll also need to connect both stacks to the same Docker network. In docker-compose.yml, uncomment the networks: sections and set the network name to match your app's network:
services:
onion-proxy:
networks:
- my-app-network
networks:
my-app-network:
external: trueFind your app's network name with docker network ls.
Your origin is a separate server accessible over the network.
./add-site.sh mysite example.com --origin https://origin.example.comThe onion-proxy will connect to the remote server, set the Host header to example.com, and rewrite URLs in the response.
Generate a custom .onion address prefix instead of a random one:
# Generate a vanity address (may take minutes to hours depending on length)
./generate-vanity.sh mysite
# Use the generated keys when adding a site
./add-site.sh mysite example.com --keys vanity-keys/mysite<...>.onionAddresses use base32 encoding — valid characters are a-z and 2-7 only.
| Prefix length | Approximate time |
|---|---|
| 4 chars | seconds |
| 5 chars | seconds to minutes |
| 6 chars | minutes to tens of minutes |
| 7 chars | hours to days |
| 8+ chars | impractical |
Options: --threads N (CPU threads) and --count N (number of addresses to generate).
Security: The
vanity-keys/directory contains private keys. Treat them like SSH keys — never commit to git (gitignored by default).
./add-site.sh <name> <domain> [--origin URL] [--port PORT] [--keys PATH]This generates:
conf.d/<name>.conf— nginx server block with URL rewriting- Appends a
HiddenServiceblock totorrc
Note: Generated configs in
conf.d/are gitignored by default. This keeps deployed configs out of version control. Usegit add -fif you need to commit one.
Then apply:
docker compose up -d
docker compose restart tor
docker compose exec tor cat /var/lib/tor/hidden_service/<name>/hostname./remove-site.sh <name>This removes the nginx config and the torrc entry. Hidden service keys are preserved by default so you can re-add the site later with the same .onion address.
To permanently destroy the .onion address:
./remove-site.sh <name> --purge-keysThen apply:
docker compose up -d
docker compose restart torTo have Tor Browser suggest your .onion to clearnet visitors, add the Onion-Location header on your clearnet reverse proxy. For example, in Traefik:
http:
middlewares:
onion-location:
headers:
customResponseHeaders:
Onion-Location: "http://YOUR_ADDRESS.onion{path}"Or in Nginx:
add_header Onion-Location http://YOUR_ADDRESS.onion$request_uri;Or in Caddy:
header Onion-Location "http://YOUR_ADDRESS.onion{path}"
The Tor container is built from Dockerfile.tor using Alpine's package repos. To pick up a new Tor release, rebuild the image:
docker compose build tor && docker compose up -dTo track Alpine releases, bump the version in Dockerfile.tor (e.g., FROM alpine:3.22) and rebuild.
The only critical data is the hidden service keys in the tor-keys volume:
docker run --rm -v tor-onion_tor-keys:/data -v $(pwd):/backup alpine \
tar czf /backup/tor-keys-backup.tar.gz -C /data .If you run a WAF (CrowdSec, ModSecurity, etc.) in front of your origin, you can route Tor traffic through it by pointing --origin at the WAF's entrypoint instead of directly at the application. For example, if your WAF is a reverse proxy on the host listening on port 443:
./add-site.sh mysite example.com --origin https://host.docker.internal:443The request flow becomes:
Tor visitor → tor → onion-proxy → WAF (on host) → origin application
The WAF sees a normal request with Host: example.com and applies its rules identically to clearnet traffic. The URL rewriting still happens at the onion-proxy level on the way back.
- Caching: If your origin has a page cache, it should already key on
Hostheader. The onion-proxy sends the clearnet hostname, so WordPress/Varnish/etc. serve from the same cache pool. No special configuration needed. - SSL on .onion: Tor provides end-to-end encryption natively, so
.onionaccess is plain HTTP. If your origin forces HTTPS redirects, make sure it only does so based onX-Forwarded-Proto(which the onion-proxy sets tohttp). - Compressed responses: The onion-proxy sets
Accept-Encoding: ""to disable upstream compression sosub_filtercan work on the raw body. It then re-compresses the response to the client viagzip on;in the generated config. www.variants:sub_filterdoes literal string matching. If your origin generates bothhttps://example.comandhttps://www.example.com, only the exact domain you specified gets rewritten. Add a secondsub_filterline to the generated config for thewww.variant, or set up a redirect fromwww.to the bare domain on your origin.