Most tutorials about Tor onion services stop at the point where the .onion address appears in a file. They show you HiddenServiceDir and HiddenServicePort, pat you on the back, and call it done. That’s not a hidden service — that’s a public service running on a different naming scheme.
This guide goes past "hello world." We’ll build a real v3 onion service with a vanity address, lock it down with client authorization so only people you invite can even discover it, and harden the entire stack against the most common deanonymization mistakes. By the end, you’ll understand why each piece exists, not just what commands to run.
Official Tor Project docs: https://tb-manual.torproject.org/ and https://gitlab.torproject.org/tpo/core/tor.
What we’re building
A v3 onion service running nginx in Docker, with:
- A vanity prefix on the
.onionaddress (e.g.,secretxyz...onion) - Client authorization — unauthorized clients can’t even tell the service exists
- Tor daemon isolated from the internet-facing network stack
- Firewall rules that make it physically impossible for the web process to talk to the clearnet
The host: any Debian/Ubuntu server. You don’t need a public IP. You don’t need a domain. You don’t need open inbound ports.
Step 0: A word on v2 vs v3
Forget v2. It was deprecated, then disabled at the Tor protocol level in 2021. All modern onion services use v3: 56-character base32 addresses derived from Ed25519 public keys. The key material lives in the HiddenServiceDir — two files, hs_ed25519_secret_key and hs_ed25519_public_key. Lose the secret key, lose the address forever. Back them up encrypted, offline.
Step 1: Install Tor
Use the official Tor Project repo — distro packages are often months behind.
apt install -y apt-transport-https gnupg curl
curl -fsSL https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc \
| gpg --dearmor -o /usr/share/keyrings/tor-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] \
https://deb.torproject.org/torproject.org $(lsb_release -cs) main" \
> /etc/apt/sources.list.d/tor.list
apt update && apt install -y tor deb.torproject.org-keyring
Stop the default Tor service — we’ll run a dedicated instance under a separate config, not the system-default one that may expose a SOCKS port to the local network.
systemctl disable --now tor
Step 2: Generate a vanity address with mkp224o
The default workflow gives you a random address. Vanity addresses serve a real purpose beyond aesthetics: they make phishing harder because users can visually verify a prefix. mkp224o is the fastest open-source v3 vanity generator, using Ed25519 batch signing tricks.
Build it from source — there’s no reason to trust a random binary for key generation:
apt install -y git libsodium-dev gcc make autoconf
git clone https://github.com/cathugger/mkp224o.git /opt/mkp224o
cd /opt/mkp224o
./autogen.sh
./configure --enable-batch --enable-amd64-51-30k # or --enable-donna on ARM
make -j$(nproc)
Now generate. The speed depends heavily on prefix length: 4 chars is seconds, 6 chars is minutes, 8 chars is hours on a single machine.
mkdir -p /opt/vanity-keys
cd /opt/mkp224o
# Generate keys starting with "secret" — adjust prefix to taste
./mkp224o -d /opt/vanity-keys -n 1 secret
-n 1 stops after the first match. The output directory will contain a subdirectory named after the full .onion address, with hs_ed25519_secret_key, hs_ed25519_public_key, and hostname inside.
ls /opt/vanity-keys/
# secretxyz7q3abc...onion/
Gotcha: mkp224o by default writes keys in a format Tor can consume directly — but verify with cat hostname that you have a v3 address (56 chars + .onion). Some forks produce broken output.
Step 3: Set up the hidden service directory
# Create a dedicated user for the Tor process
useradd -r -s /usr/sbin/nologin -d /var/lib/tor-hs tor-hs
mkdir -p /var/lib/tor-hs/service
chown -R tor-hs:tor-hs /var/lib/tor-hs
chmod 700 /var/lib/tor-hs/service
# Copy vanity keys in
cp /opt/vanity-keys/secret*/hs_ed25519_secret_key /var/lib/tor-hs/service/
cp /opt/vanity-keys/secret*/hs_ed25519_public_key /var/lib/tor-hs/service/
chown tor-hs:tor-hs /var/lib/tor-hs/service/hs_ed25519_*
chmod 600 /var/lib/tor-hs/service/hs_ed25519_*
Step 4: Client authorization (stealth onions)
v3 onion services use x25519 key pairs for client authorization — completely redesigned from the old v2 cookie/stealth mechanism. If you add at least one authorized client, Tor encrypts the service descriptor so unauthorized clients receive nothing — not even a "connection refused." The service is invisible to them.
Generate a client keypair
# On the server, or on the client machine and transfer the public key
python3 - <<'EOF'
import base64, os
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
priv = X25519PrivateKey.generate()
pub = priv.public_key()
priv_b32 = base64.b32encode(priv.private_bytes_raw()).decode().rstrip('=')
pub_b32 = base64.b32encode(pub.public_bytes_raw()).decode().rstrip('=')
print(f"Private (client keeps): {priv_b32}")
print(f"Public (server gets): {pub_b32}")
EOF
No cryptography library? Install it: pip3 install cryptography. Or use openssl genpkey -algorithm x25519 and convert — but the Python one-liner is cleaner.
Alternatively, the tor package ships tor-instance-create and you can use the client-auth-gen utility from the torpy project, or grab onionshare-cli which includes helpers. The math is standard — any x25519 implementation works.
Wire up the server side
Create the authorized_clients directory inside the service dir:
mkdir -p /var/lib/tor-hs/service/authorized_clients
chown tor-hs:tor-hs /var/lib/tor-hs/service/authorized_clients
chmod 700 /var/lib/tor-hs/service/authorized_clients
Add one .auth file per client. The filename is the client’s label:
cat > /var/lib/tor-hs/service/authorized_clients/alice.auth <<EOF
descriptor:x25519:BCDDEFG... (alice's public key base32, no padding)
EOF
chown tor-hs:tor-hs /var/lib/tor-hs/service/authorized_clients/alice.auth
chmod 600 /var/lib/tor-hs/service/authorized_clients/alice.auth
Revoking access later is just rm alice.auth and a SIGHUP to Tor — no key rotation needed for other clients.
Wire up the client side
The person connecting needs a .auth_private file. They put it in a directory pointed to by ClientOnionAuthDir in their torrc:
# client's torrc
ClientOnionAuthDir /home/alice/.tor/onion-auth
# client machine
mkdir -p ~/.tor/onion-auth
cat > ~/.tor/onion-auth/secretxyz.auth_private <<EOF
secretxyz7q3abc...onion:descriptor:x25519:PRIVATEKEY_BASE32_HERE
EOF
chmod 600 ~/.tor/onion-auth/secretxyz.auth_private
Gotcha: Tor Browser users can add keys through the browser’s circuit display UI (Settings → Tor → Onion Services). They don’t edit files. Share the private key value with them, not the file.
Step 5: Write the Tor config
cat > /etc/tor/torrc-hs <<'EOF'
# Dedicated Tor instance for our hidden service
# No SOCKS proxy — this instance only serves the hidden service
SocksPort 0
ControlPort 0
DataDirectory /var/lib/tor-hs/data
PidFile /var/run/tor-hs/tor-hs.pid
User tor-hs
# Hidden service definition
HiddenServiceDir /var/lib/tor-hs/service
HiddenServicePort 80 127.0.0.1:8080 # forward .onion:80 → local nginx on 8080
# Harden circuit behavior
HiddenServiceMaxStreams 64
HiddenServiceMaxStreamsCloseCircuit 1
# Restrict to only guard and middle relays with good uptime
# (optional, trades latency for stability)
# LongLivedPorts 80
# No exit traffic — this node doesn't relay anything
ExitPolicy reject *:*
ExitRelay 0
# Logging — to syslog or file, not stdout
Log notice syslog
# Optional sandbox (Linux seccomp, reduces syscall surface)
Sandbox 1
EOF
Create the data and runtime directories:
mkdir -p /var/lib/tor-hs/data /var/run/tor-hs
chown -R tor-hs:tor-hs /var/lib/tor-hs/data /var/run/tor-hs
chmod 700 /var/lib/tor-hs/data
Write a systemd unit:
# /etc/systemd/system/tor-hs.service
[Unit]
Description=Tor Hidden Service
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=tor-hs
Group=tor-hs
RuntimeDirectory=tor-hs
ExecStartPre=/usr/bin/tor --verify-config -f /etc/tor/torrc-hs
ExecStart=/usr/bin/tor -f /etc/tor/torrc-hs
Restart=on-failure
RestartSec=5s
LimitNOFILE=65536
# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/tor-hs /var/run/tor-hs
ProtectHome=yes
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now tor-hs
journalctl -u tor-hs -f
# Wait for: "Self-testing indicates your ORPort is reachable" and
# "Tor has successfully opened a circuit."
Step 6: The web service — nginx in Docker
The web process must never touch the internet directly. We’ll bind nginx to 127.0.0.1:8080 only and put it in a Docker network with no external routing.
# docker-compose.yml
services:
nginx:
image: nginx:alpine
container_name: onion-web
restart: unless-stopped
networks:
- onion-internal
ports:
# Only bind to loopback — Tor connects here
- "127.0.0.1:8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./www:/var/www/html:ro
networks:
onion-internal:
# No external access — containers on this network are isolated
internal: true
driver: bridge
# nginx.conf
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
# Strip any headers that could leak the real server
server_tokens off;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
# No logging of client IPs beyond what Tor strips anyway
access_log /var/log/nginx/access.log combined;
error_log /var/log/nginx/error.log warn;
location / {
try_files $uri $uri/ =404;
}
}
docker compose up -d
curl -s http://127.0.0.1:8080/ # verify it responds locally
Step 7: Firewall — the most important step
This is where most guides skip out. Without firewall rules, a bug in your app or a misconfigured bind could expose the service on a public interface, or the service process could make outbound requests that reveal the host IP.
Using nftables (preferred on modern kernels):
cat > /etc/nftables-onion.conf <<'NFTEOF'
table inet filter-hs {
chain input {
type filter hook input priority 0; policy drop;
# Allow established connections
ct state {established, related} accept
# Allow SSH (adjust port if needed)
tcp dport 22 accept
# Allow Tor process to reach local nginx
# tor-hs uid = $(id -u tor-hs)
# This rule accepts traffic already in the loopback — handled by lo accept below
iif lo accept
# Drop everything else inbound
}
chain output {
type filter hook output priority 0; policy accept;
# Block nginx container from making outbound clearnet connections
# The nginx process runs as uid 101 inside Docker, but from the host
# perspective it appears as the Docker bridge IP range
# Block all output from the Docker bridge network except to loopback
ip saddr 172.17.0.0/12 ip daddr != 127.0.0.0/8 drop
}
chain forward {
type filter hook forward priority 0; policy drop;
# Allow Docker internal traffic only
iif "onion-internal" oif "onion-internal" accept
}
}
NFTEOF
nft -f /etc/nftables-onion.conf
Gotcha: Docker manipulates iptables/nftables directly when containers start, which can bypass your rules. Set "iptables": false in /etc/docker/daemon.json if you’re managing rules yourself, or use --network=none for the container and add explicit veth rules. Test with nc -zv google.com 80 from inside the container — it should fail.
Step 8: Vanguards — protecting against guard discovery
The default Tor hidden service setup is vulnerable to guard discovery attacks where an adversary sends many circuits to your service and tries to figure out which guard nodes you’re using, eventually narrowing down your location. The Tor Project developed the vanguards addon specifically for this.
pip3 install vanguards
# Enable ControlPort in torrc-hs (for vanguards to communicate with Tor)
# Add to /etc/tor/torrc-hs:
# ControlPort 127.0.0.1:9151
# CookieAuthentication 1
systemctl restart tor-hs
# Run vanguards
vanguards --control_port 9151 --state_file /var/lib/tor-hs/vanguards.state
Run it as a systemd service. In practice, for high-security deployments this is non-optional — it meaningfully raises the bar for targeted deanonymization.
Step 9: Verify the setup
From a machine running Tor Browser (with the client auth key installed):
# Via torsocks on a client with Tor running
torsocks curl -s http://secretxyz...onion/
Without the auth key, a client should see a connection timeout or a "Could not connect" error — not a TLS certificate error, not an nginx error page. Invisible is the goal.
From the server side, confirm Tor is publishing the descriptor:
journalctl -u tor-hs | grep -i "descriptor"
# Should see: "Service descriptor (v3) ... successfully uploaded"
Operational gotchas
Key backups. The two hs_ed25519_* files are your identity. Encrypt and archive them with gpg --symmetric off-host before anything else. No backup = no recovery if the disk dies.
The hostname file. After first start, Tor writes /var/lib/tor-hs/service/hostname. This is just the public key hash — it’s safe to display. The secret key file is the one to protect.
Log hygiene. nginx logs client IPs, but through Tor those are always Tor exit IPs for standard services. For onion services, the "client IP" in nginx logs will be 127.0.0.1 since Tor proxies locally. Still, rotate logs, don’t ship them to a clearnet logging SaaS, and consider whether you need access logs at all.
Tor version pinning. The Tor Project occasionally deprecates configuration options between minor versions. Pin to a minor version in your apt preferences if stability matters more than getting the absolute latest features.
Don’t use SingleHopMode. HiddenServiceSingleHopMode makes your service faster by reducing the circuit to one hop, but it completely destroys location anonymity and is only appropriate for official Tor network infrastructure. If you’re running a real hidden service, don’t touch this.
Time synchronization. Tor is sensitive to clock skew. Make sure systemd-timesyncd or chrony is running. More than a few minutes of drift and your service will have trouble building circuits.
Production-ready additions
-
HTTPS on the
.onion. You can get a CA-signed certificate for your.onionaddress from HARICA or DigiCert (they support v3 onion DV certs under CAB Forum rules). It adds very little security for a well-hardened onion — the Tor connection is end-to-end encrypted — but it satisfies browser UI expectations and removes the "not secure" warning. -
Rate limiting. Add nginx
limit_req_zoneandlimit_conn_zoneto protect against circuit-flooding DoS where an adversary opens thousands of streams to exhaust your resources. -
Monitoring without leaking. Export Tor metrics locally (
MetricsPort 127.0.0.1:9035) and scrape with a local Prometheus — never ship metrics to a clearnet endpoint. -
Separate VMs. The gold standard: Tor and the web service run in separate VMs with an internal network between them, and the web service VM has no routing table entry for any external network. Qubes OS makes this trivial if you’re running a desktop; on a server, libvirt/KVM with a private bridge does the same job.
Running a serious onion service is a patience game. The crypto is solid — the threat model lives in your operational decisions: what you log, what you expose, how you back up keys, and whether your app leaks identifying information in its responses. Get those right, and the address itself is genuinely unfindable.