Skip to content
Go back

Tor Hidden Services for Self-Hosters

By SumGuy 15 min read
Tor Hidden Services for Self-Hosters
Contents

Stop. Before you expose your home lab to the internet

You’ve got Nextcloud running. Maybe a Matrix server. Or you’re thinking about it. And your brain immediately goes: “I’ll just use Cloudflare Tunnels and call it a day.”

Tunnels are fine. They work. But you’re still giving Cloudflare a clear view of what’s inside. They terminate TLS, so they see every request and every endpoint. They’re not malicious, but they’re not your grandmother either.

Tor onion services flip the arrangement. Instead of exposing an IP to the world, you hand out a .onion address. Your ISP, Cloudflare, and anyone scanning the IPv4 space have no route back to the machine, because there is no inbound port to find. The service builds outbound circuits and waits.

Setting it up takes about fifteen minutes. Keeping it actually hidden is the part people get wrong, and that is most of this post.

What is a Tor onion service, actually?

A regular server is a storefront: the address is on the sign, people walk in. An onion service is a speakeasy. It exists, but it is not on the map, and the client and the server each learn nothing about the other’s network location.

Here is what happens when someone opens your .onion address:

  1. Your Tor daemon picks a few relays to act as introduction points and publishes a signed descriptor listing them to the hash ring of directory servers (HSDirs). The descriptor is indexed by your onion address.
  2. The visitor’s Tor client derives the same HSDir position from the address, fetches the descriptor, and learns your introduction points.
  3. The client picks its own relay to be the rendezvous point, then asks one of your introduction points to pass along a one-time cookie and the rendezvous location.
  4. Your service builds a fresh circuit out to that rendezvous point. Both sides now have a three-hop circuit meeting at a relay neither of you can influence, six hops end to end.
  5. Traffic flows through that circuit, encrypted end to end between the client and your service, on top of Tor’s own layered encryption.

Nobody in the path sees both ends. The introduction points never carry your data. The rendezvous point carries the data but cannot read it and does not know who either party is.

The threat model: who are you actually protecting against?

Before you obsess over onion addresses, work out what you are protecting against.

Mass port scanners. Shodan, Censys, and anyone renting a botnet cannot find a .onion service unless you publish the address. There is no listening socket on your WAN IP to fingerprint.

Your ISP. They can see that you speak Tor. They cannot see which service, or that you are running one rather than browsing.

Passive network snooping. Someone on the coffee shop WiFi between a visitor and your service sees Tor circuit traffic. The payload is encrypted end to end between the visitor’s client and your daemon, so there is no certificate to swap and no plaintext to read.

What onion services do not protect against:

Know your adversary. If you only want your ISP to stop seeing which of your services people hit, a tunnel already does that. If you want the service itself to have no discoverable network location, that is what onion services are for.

Setting up an onion service

You run Tor on the server, point it at a local port where the app lives, and Tor publishes a .onion address. The flow for inbound traffic:

Visitor's Tor Browser
|
Tor network (rendezvous circuit)
|
Your Tor daemon (no inbound port on your WAN IP)
|
Reverse proxy (127.0.0.1:8080)
|
Your app (127.0.0.1:3000)

Note the SOCKS port is not in that path. SocksPort is for outbound Tor client traffic. A machine that only publishes a service does not need it, so turn it off.

Here is the torrc:

/etc/tor/torrc
# No outbound client proxy on a service-only host
SocksPort 0
HiddenServiceDir /var/lib/tor/hidden_service/
HiddenServicePort 80 127.0.0.1:8080
# Rate-limit introduction requests at the intro points
HiddenServiceEnableIntroDoSDefense 1
HiddenServiceEnableIntroDoSRatePerSec 25
HiddenServiceEnableIntroDoSBurstPerSec 200
Log notice syslog

Key points:

Restart and read the address:

Terminal window
sudo systemctl restart tor
sudo cat /var/lib/tor/hidden_service/hostname
unqqdxt6p4nevvml5ligkzwh2r7iqegtkusnoyadc4hbx2xknrryiwsd.onion

That is always exactly 56 characters, drawn from the base32 alphabet: lowercase a to z and the digits 2 to 7. There is no 0, 1, 8 or 9 in a valid onion address. It encodes the 32-byte public key, a 2-byte checksum and a version byte, which is why every v3 address ends in d.

Running Tor in a container

If you’d rather keep it in Compose, this pairs the Tor daemon with Caddy and your app on a private bridge network:

docker-compose.yaml
services:
tor:
image: osminogin/tor-simple
volumes:
- tor_data:/var/lib/tor
- ./torrc:/etc/tor/torrc:ro
networks:
- internal
restart: unless-stopped
reverse_proxy:
image: caddy:2-alpine
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
networks:
- internal
depends_on:
- tor
restart: unless-stopped
your_app:
image: your-nextcloud-or-whatever
expose:
- "3000"
networks:
- internal
restart: unless-stopped
volumes:
tor_data:
networks:
internal:
driver: bridge

Look at what is missing: there is no ports: key anywhere. Nothing in this stack publishes a port on the host. That is the point. Publishing 9050:9050 and 9051:9051 “for debugging” is how people hand out an unauthenticated Tor control port to their whole LAN, and the control port can read the service key and rewrite the config.

The torrc sits next to the compose file:

torrc
SocksPort 0
HiddenServiceDir /var/lib/tor/hidden_service/
HiddenServicePort 80 reverse_proxy:8080
Log notice stdout

And the Caddyfile:

Caddyfile
:8080 {
reverse_proxy your_app:3000
encode gzip
}

Bind the site block to :8080, not localhost:8080. Inside a container, localhost is that container’s own loopback, so a localhost:8080 block refuses the connection arriving from the Tor container and you get a blank page with no useful log line.

Skip TLS entirely. There is no certificate authority that will issue for a .onion name you generated five minutes ago, tls internal throws a warning page in Tor Browser, and the circuit is already end-to-end encrypted and authenticated to your public key. Plain HTTP on virtual port 80 is the norm for onion services.

Grab the address once it starts:

Terminal window
docker compose exec tor cat /var/lib/tor/hidden_service/hostname

Restricting access with client authorization

Handing an address to five people is not access control. The moment one of them pastes it into a Discord channel, anyone can fetch your descriptor and connect. v3 client authorization fixes that properly: without a key, a client cannot even decrypt the descriptor, so your introduction points stay invisible.

Each authorized client gets an x25519 keypair. Drop their public key in a .auth file:

Terminal window
mkdir -p /var/lib/tor/hidden_service/authorized_clients
echo "descriptor:x25519:<their-base32-public-key>" \
> /var/lib/tor/hidden_service/authorized_clients/alice.auth
chown -R tor:tor /var/lib/tor/hidden_service
chmod 700 /var/lib/tor/hidden_service
systemctl reload tor

The filename is arbitrary and the file must be a single line. Tor enables client authorization for the service as soon as it loads at least one valid file, so the first .auth you add locks everyone else out. Revoking someone is a file deletion and a reload. That beats rotating a shared password across five people.

Network isolation: the boring part that matters

Your onion address is unfindable. Your WAN IP is not, and it never will be. The failure mode is correlation: an observer who can link the two learns where the service lives.

The rules that keep them apart:

  1. Nothing publishes a host port. No 0.0.0.0 binds, no helpful debugging ports. Check with ss -tlnp and look for anything not on loopback.
  2. The app answers on the onion name only. If the same app instance also answers on home.example.com, a single response-header or favicon match links the two. Run separate instances with separate data if you need both.
  3. The app never fetches from the clearnet on a page load. Google Fonts, a CDN-hosted script, an avatar gravatar: each one is a request your visitor’s browser makes outside Tor’s protection, and each one tells a third party who is looking at your service.
  4. Access it through Tor Browser. Firefox pointed at a SOCKS port will resolve and load an onion address, but it fingerprints like your everyday browser and it is one extension update away from leaking a clearnet request.

The mistake people make is running a public clearnet site on 0.0.0.0:8080 beside the onion service, on the same container and the same database. That is hiring a forklift to move a couch. One scan of the clearnet port and both identities collapse into one.

Getting a vanity .onion address

Default addresses are random. Some people want a recognizable prefix, so they reach for mkp224o, which brute-forces keypairs until one encodes the characters you asked for:

Terminal window
mkdir onion_search
mkp224o -d onion_search sumguy

Two things people get wrong here.

First, a vanity address is a prefix, not a name. There is no such thing as a short onion address. You will get sumguy followed by 50 more random characters, because the address is a fixed-length encoding of a public key and always will be.

Second, the cost curve is steeper than it looks. Each extra character multiplies the search space by 32. On a capable machine, mkp224o’s own documentation puts a 6-character prefix at tens of minutes with batch mode enabled, and 7 characters at hours to days. Eight is a weekend you will not get back. And the alphabet has no 0, 1, 8 or 9, so s3lfh0st is not a filter you can even ask for.

When it lands, the keypair is written to onion_search/<address>.onion/. Copy the directory contents into your HiddenServiceDir, fix the ownership to the tor user and the mode to 700, and restart.

Worth knowing: a vanity prefix buys recognizability, and recognizability is exactly what phishers copy. Several onion directories have hosted lookalikes that match the first six characters of a well-known service. Users still have to check all 56.

Operational security: the ways you’ll accidentally doxx yourself

You’ve got an onion service. Now don’t blow it up.

Reusing a handle. A username you also use on GitHub, a support email on your own domain, a PGP key you signed a public commit with: any of these turns “an anonymous onion service” into “that guy’s onion service”.

Server-side logging. Every request over an onion service arrives from 127.0.0.1, so your access log is useless for abuse handling and dangerous for you. If you or a housemate ever hit the service over clearnet by mistake, that log now holds the correlation you spent this whole post avoiding. Turn access logging off or ship it nowhere.

Timestamps in generated content. An app that stamps local time in email headers, RSS pubdates, or exported files publishes your timezone on every item.

Loose key permissions. HiddenServiceDir must be mode 700, owned by the user Tor drops to. Tor refuses to start otherwise, which is a good default, but a chmod -R 755 during a debugging session will happily break it in the other direction:

Terminal window
sudo ls -la /var/lib/tor/hidden_service/
# drwx------ 3 tor tor ... hidden_service

Backups. Your key ends up in whatever backs up /var/lib. If that backup goes to a cloud bucket under your real account, your onion identity now lives in a provider’s storage tied to your credit card. Encrypt it client-side, or exclude the directory and keep the key somewhere you control.

When to use an onion service (and when not to)

Use one if:

Don’t use one if:

There is a middle option worth knowing about: run the clearnet site as usual and advertise the onion as an alternative with the Onion-Location HTTP header. Tor Browser shows a “Go to onion site” button when it sees one. That gives Tor users a better path without pretending the clearnet site does not exist.

In short

An onion service lets you publish something without publishing a network location, and the config is four lines.

Set SocksPort 0, point HiddenServicePort 80 at a reverse proxy on loopback or a container name, publish no host ports, and read the hostname file. Add client authorization when the address alone is not enough. Then spend your effort on the part Tor cannot do for you: keeping the clearnet identity and the onion identity from ever touching the same log, the same database, or the same username.

Common Questions

Can I run a Tor onion service and a public website on the same server?

Yes, technically, but run them as separate application instances with separate data. Sharing one instance leaks correlation through response headers, favicons, error pages and logs. If the two must share content, accept that they are publicly the same operator and use the Onion-Location header deliberately instead of hiding it.

Do I need an SSL certificate for a .onion address?

No. Traffic to an onion service is already encrypted end to end and authenticated to the service’s public key, which is the address itself. No public CA issues for self-generated onion names, and Caddy’s tls internal produces a warning page in Tor Browser. Serve plain HTTP on virtual port 80.

How long does generating a vanity .onion address take?

A 6-character prefix takes tens of minutes on a capable machine with mkp224o batch mode enabled. Seven characters takes hours to days. Each additional character multiplies the search by 32. The digits 0, 1, 8 and 9 are not in the base32 onion alphabet, so prefixes containing them are impossible.

Is a Tor onion service slower than a Cloudflare Tunnel?

Yes, noticeably. An onion circuit is six relay hops with no geographic optimization, so expect several hundred milliseconds of added latency and seconds on the first connection. A Cloudflare Tunnel adds tens of milliseconds. Onion services trade throughput and latency for having no discoverable network location.

Can I limit my onion service to specific people?

Yes, with v3 client authorization. Put each person’s x25519 public key in its own .auth file under authorized_clients/ in your HiddenServiceDir. Clients without a key cannot decrypt the service descriptor, so they never learn your introduction points. Revoking access is deleting a file and reloading Tor.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Discord Alternatives That Actually Work
Next Post
Anubis: Anti-AI-Crawler Proof-of-Work

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts