TechEarl

Host a v3 .onion Hidden Service with Tor

End-to-end setup for a v3 .onion hidden service — torrc HiddenServiceDir and HiddenServicePort, key backup, permissions, onion-location header, single-onion mode, and the operational mistakes that get addresses leaked or lost.

Ishan KarunaratneIshan Karunaratne⏱️ 8 min readUpdated
Stand up a v3 .onion hidden service with Tor. HiddenServiceDir, HiddenServicePort, key backup, permissions, onion-location, single-onion mode, and operational gotchas.

A v3 .onion hidden service is the modern way to expose a TCP service over Tor without revealing the server's IP. The address is a 56-character base32 fingerprint of an ed25519 public key, ending in .onion. The setup is three lines of torrc plus a daemon reload — but the operational details (key backup, file permissions, advertising the service from your clearnet site) are where most first deployments go wrong. Here's the complete walkthrough, including the single-onion fast path and the failure modes that cost addresses.

What you need

  • A server with the Tor daemon installed.
  • A local service the .onion will proxy to (a webserver on 127.0.0.1:8080, an SSH daemon, anything TCP).
  • Root or sudo access to edit torrc and the Tor data directory.

v3 onion has been the only supported version since Tor 0.4.6 (2021). If your distro's Tor is older than that, upgrade first — v2 is gone and won't come back.

Step 1: define the service in torrc

Add this block to your torrc (location depends on install — /etc/tor/torrc on Debian/Ubuntu):

text
HiddenServiceDir /var/lib/tor/myservice/
HiddenServicePort 80 127.0.0.1:8080

HiddenServiceDir is where Tor will store the .onion private key and the public hostname for this service. It must be a path the tor user can read and write, and must have permissions 700 (owner-only). Tor will refuse to start if it's 755 or world-readable.

HiddenServicePort is the mapping: public onion port (left) to local backend (right). One line per port. To also expose HTTPS:

text
HiddenServiceDir /var/lib/tor/myservice/
HiddenServicePort 80 127.0.0.1:8080
HiddenServicePort 443 127.0.0.1:8443

For an SSH-over-onion service:

text
HiddenServiceDir /var/lib/tor/ssh-onion/
HiddenServicePort 22 127.0.0.1:22

Each HiddenServiceDir defines one onion address. To run two distinct services with two different .onion addresses, declare two blocks with different directories.

Step 2: reload Tor and read the address

bash
sudo systemctl reload tor
# or
sudo pkill -HUP tor

# Tor creates the directory on first start. Wait a few seconds, then:
sudo cat /var/lib/tor/myservice/hostname
# myveryunlikelyloockingvanityaddrthatlls45thinglooksokayyhotya2id.onion

That .onion string is the public address. There's no DNS to register, no certificate to buy, no port to forward. It just works — provided your local service on 127.0.0.1:8080 is actually responding.

Test from another machine that has Tor:

bash
torsocks curl -v http://myveryunlikely...id.onion/

Or open it in Tor Browser.

Step 3: back up the private key

This is the step people skip and then bitterly regret. The .onion address is derived from the keypair in:

code
/var/lib/tor/myservice/
├── authorized_clients/       # for client authorization, optional
├── hostname                  # the .onion address (public, derived)
├── hs_ed25519_public_key     # public key (derivable from private)
└── hs_ed25519_secret_key     # PRIVATE KEY — losing this loses the address forever

Back up hs_ed25519_secret_key somewhere safe. Anyone with that file can impersonate your .onion address from any machine. Treat it like an SSH host key, but with no recovery path.

A reasonable backup workflow:

bash
# Encrypted, single-file, off-host
sudo tar czf - -C /var/lib/tor/myservice/ . \
  | gpg --symmetric --cipher-algo AES256 \
  > myservice-onion-backup-$(date +%F).tar.gz.gpg

Move the resulting file to a different host or cold storage. If your server dies tomorrow, you can recreate the .onion address by un-encrypting the backup, dropping it into HiddenServiceDir on a new machine, and reloading Tor.

Permissions checklist

Tor is paranoid about file permissions on hidden service data. If the daemon fails to start, this is usually why:

bash
sudo ls -la /var/lib/tor/myservice/
# Should show:
# drwx------ 2 debian-tor debian-tor   ...  .
# drwxr-xr-x 5 debian-tor debian-tor   ...  ..
# -rw------- 1 debian-tor debian-tor   ...  hs_ed25519_public_key
# -rw------- 1 debian-tor debian-tor   ...  hs_ed25519_secret_key
# -rw------- 1 debian-tor debian-tor   ...  hostname

The directory must be 700, files must be 600, all owned by the tor user (named debian-tor on Debian, _tor on macOS, tor on Fedora). Fix with:

bash
sudo chmod 700 /var/lib/tor/myservice
sudo chmod 600 /var/lib/tor/myservice/*
sudo chown -R debian-tor:debian-tor /var/lib/tor/myservice

Advertising from clearnet: the Onion-Location header

If you also have a clearnet site, Tor Browser will auto-suggest the .onion equivalent when it sees the Onion-Location header. Set it in your reverse proxy:

nginx
# nginx
add_header Onion-Location "http://myveryunlikely...id.onion$request_uri" always;
apache
# Apache
Header set Onion-Location "http://myveryunlikely...id.onion%{REQUEST_URI}s"

When a Tor Browser user visits the clearnet site, the address bar shows a .onion badge offering to switch. This is the recommended cross-link pattern — see the Tor Project's onion-location docs for the spec.

Single-onion mode (faster, no service-side anonymity)

The default v3 onion routes both client and service through 3-hop circuits, giving anonymity to both ends. For high-traffic public services where the operator's location is already known (a corporate onion, a public news outlet's mirror), single-onion mode cuts the service-side circuit to a single hop:

text
HiddenServiceDir /var/lib/tor/myservice/
HiddenServicePort 80 127.0.0.1:8080
HiddenServiceSingleHopMode 1
HiddenServiceNonAnonymousMode 1

Both lines are required (and both must appear in torrc, not via SETCONF). The trade-off is explicit: faster, simpler, more reliable connections, but the .onion no longer hides where the server is. Clients still get full anonymity — that side hasn't changed.

Single-onion is the right mode for: high-traffic public mirrors (Facebook's facebookcorewwwi.onion-style), CDN-style content where geographic origin isn't sensitive, internal services where you control all the clients.

Operational tuning

For services that need to scale:

text
HiddenServiceDir /var/lib/tor/myservice/
HiddenServicePort 80 127.0.0.1:8080

# More introduction points = more resilient to one going down,
# more rendezvous traffic. Default is 3; max useful is around 10.
HiddenServiceNumIntroductionPoints 6

# Cap streams per circuit to mitigate burst abuse
HiddenServiceMaxStreams 50
HiddenServiceMaxStreamsCloseCircuit 1

# Don't lazily build new circuits between requests
HiddenServiceUseStrictGuards 1

HiddenServiceMaxStreamsCloseCircuit 1 causes Tor to drop the circuit (rather than just the offending stream) when a client exceeds HiddenServiceMaxStreams. Useful for cheap denial-of-service mitigation.

Vanity .onion addresses with mkp224o

A v3 onion is 56 base32 characters of an ed25519 public key fingerprint. You can grind the keypair until the address starts with a chosen prefix:

bash
# Build mkp224o once
git clone https://github.com/cathugger/mkp224o
cd mkp224o
./autogen.sh && ./configure && make

# Grind for an address starting with "techea"
./mkp224o -d ./output techea

Each character of prefix multiplies the search space by 32. Three-character prefixes complete in seconds. Six characters take minutes to hours on a fast CPU. Eight characters and beyond want a GPU or a server farm.

Once mkp224o finds a match, it writes a directory containing hs_ed25519_secret_key, hs_ed25519_public_key, and hostname — drop the whole directory into your HiddenServiceDir, reload Tor, and the vanity address is live.

Client authorization (private onion)

To restrict access to a list of pre-shared clients (a private onion):

text
HiddenServiceDir /var/lib/tor/myservice/
HiddenServicePort 80 127.0.0.1:8080
HiddenServiceAuthorizeClient v3-stealth

You generate a per-client x25519 keypair, install the public key in HiddenServiceDir/authorized_clients/clientname.auth, and the client adds the private key to their own torrc:

text
ClientOnionAuthDir /var/lib/tor/onion_auth

With a matching .auth_private file in that directory. Without the key, the .onion address is unreachable — descriptors don't even publish to the consensus.

What to monitor

A live hidden service should be visible in the Tor consensus once descriptors propagate (60-90 seconds after start). Check with nyx:

bash
sudo nyx
# press 'i' for the connection list
# you should see HSRendezvous and HSIntro circuits

If you see only client circuits and no HS* entries minutes after starting, the service didn't register — usually a permissions problem or torrc syntax error. Check journalctl -u tor for errors.

FAQ

TagsTOROnion ServiceHidden Servicev3 oniontorrcPrivacyAnonymitySelf-hosting
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Match a domain name with regex. Basic labels, RFC 1035 length rules, subdomains, IDN punycode, trailing-dot form, JavaScript / Python / PHP examples, engine notes, and common mistakes.

How to Match a Domain Name with Regex

Match a domain name with regex. Basic labels, RFC 1035 length rules, subdomains, IDN punycode, trailing-dot form, JavaScript / Python / PHP examples, engine notes, and common mistakes.

Match a hex color code with regex. 3-digit, 6-digit, and 8-digit (alpha) forms. Case-insensitive. JavaScript / Python / PHP examples, engine notes, common mistakes, test cases.

How to Match a Hex Color Code with Regex

Match a hex color code with regex. 3-digit, 6-digit, and 8-digit (alpha) forms. JavaScript / Python / PHP examples, engine notes, common mistakes, a stripped-hash variant.