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:
- 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.
- The visitor’s Tor client derives the same HSDir position from the address, fetches the descriptor, and learns your introduction points.
- 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.
- 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.
- 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:
- Metadata and traffic patterns. If you are the only person who ever logs into your own password manager, Tor cannot hide that fact from anyone who already suspects it. An attacker who watches both your uplink and the service can attempt confirmation over time.
- Application leaks. If your app renders its own hostname in a password-reset email, embeds a clearnet CDN, or prints a stack trace with the LAN IP, the OS is doing its job and the app is undoing it.
- Identity reuse. A login page that greets you by the same handle you use on GitHub does the deanonymising for free.
- A compromised endpoint. If the browser or the server is owned, the circuit is irrelevant.
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:
# No outbound client proxy on a service-only hostSocksPort 0
HiddenServiceDir /var/lib/tor/hidden_service/HiddenServicePort 80 127.0.0.1:8080
# Rate-limit introduction requests at the intro pointsHiddenServiceEnableIntroDoSDefense 1HiddenServiceEnableIntroDoSRatePerSec 25HiddenServiceEnableIntroDoSBurstPerSec 200
Log notice syslogKey points:
- HiddenServiceDir is where Tor writes your
hs_ed25519_secret_key. Guard it like a password. Whoever holds that file can publish a descriptor for your address and serve whatever they like to your users. - HiddenServicePort maps a virtual port to a local target.
HiddenServicePort 80 127.0.0.1:8080means visitors reachhttp://<address>.onion/and Tor forwards to your proxy. You can also target a unix socket, which is tidier:HiddenServicePort 80 unix:/run/myapp/http.sock. - HiddenServiceVersion takes only one value now. v3 is the default and the sole supported version, so there is nothing to set. The old 16-character v2 addresses were deprecated in 2020 and the code was removed in 2021.
- The intro-point DoS defense pushes rate and burst limits to the relays fronting your service, so a flood is dropped before it reaches your circuit. The defaults are 25 introductions per second with a burst of 200.
Restart and read the address:
sudo systemctl restart torsudo cat /var/lib/tor/hidden_service/hostnameunqqdxt6p4nevvml5ligkzwh2r7iqegtkusnoyadc4hbx2xknrryiwsd.onionThat 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:
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: bridgeLook 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:
SocksPort 0HiddenServiceDir /var/lib/tor/hidden_service/HiddenServicePort 80 reverse_proxy:8080Log notice stdoutAnd the 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:
docker compose exec tor cat /var/lib/tor/hidden_service/hostnameRestricting 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:
mkdir -p /var/lib/tor/hidden_service/authorized_clientsecho "descriptor:x25519:<their-base32-public-key>" \ > /var/lib/tor/hidden_service/authorized_clients/alice.authchown -R tor:tor /var/lib/tor/hidden_servicechmod 700 /var/lib/tor/hidden_servicesystemctl reload torThe 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:
- Nothing publishes a host port. No
0.0.0.0binds, no helpful debugging ports. Check withss -tlnpand look for anything not on loopback. - 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. - 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.
- 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:
mkdir onion_searchmkp224o -d onion_search sumguyTwo 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:
sudo ls -la /var/lib/tor/hidden_service/# drwx------ 3 tor tor ... hidden_serviceBackups. 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:
- You want a service with no discoverable network location, not just a hidden origin
- You are hosting for people who need the connection itself to be unremarkable
- You want access limited to a key list, with revocation, using client authorization
- You are behind CGNAT and cannot forward a port anyway (onion services need no inbound port at all)
Don’t use one if:
- You want search traffic. Onion addresses do not rank, and Google does not crawl them.
- You need low latency. Six hops means hundreds of milliseconds to seconds, and the first connection to a cold service is the slowest.
- You want normal people to find you. Reaching a
.onionaddress means installing Tor Browser first. - Your adversary is already on the box. Tor protects the network path, nothing above it.
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.