$ xmrhost-cli docs show --topic=provision-tor-hidden-service
[$ ] doc: provision-tor-hidden-service
// Provision a v3 Tor hidden service on a XMRHost VPS — hardened tor.conf, mkp224o vanity, sshd lockdown
// published=2026-04-29 · updated=2026-04-29 · diff=advanced · read=22min · tags=[tor, onion, hidden-service, hardening, vps]
// ABSTRACT
abstract
End-to-end procedure for bringing up a fresh v3 onion service on a Monero-paid XMRHost VPS. Covers the upstream tor repo install, the hardened torrc shape for onion-only workloads, mkp224o vanity address generation with realistic time budgets, the systemd unit limits, the sshd lockdown that has to land BEFORE the onion goes live, and the four post-launch checks that catch the most common misconfigurations (clearnet leak, hostname rotation, DataDirectory permissions, rendezvous descriptor publication).
Scope and assumptions
This is an operational walk-through for bringing up a single v3 onion service on a fresh XMRHost VPS in Iceland or Romania, billed in Monero. It is not an introduction to Tor; you are expected to have read the rend-spec-v3 and to know what an HSDir is. [Tor rend-spec-v3] The end state of this procedure is: a *.onion address that resolves over the live consensus, hosts a minimal nginx instance bound to 127.0.0.1, has no clearnet listener whatsoever, and ships logs only to a local file with notice-level verbosity.
Baseline assumptions:
- Debian 12 (bookworm) — package names are identical on Ubuntu 22.04 / 24.04 LTS.
- Root via SSH key only. The brand’s
hardened-by-defaultbaseline (the brand spec §3.2) is in place:PasswordAuthentication no,PermitRootLogin prohibit-password, fail2ban active on sshd,unattended-upgradesenabled,auditdconfigured. - The upstream provider has been told that Tor traffic is acceptable on this box. The procurement questions for that conversation are listed in the
run-non-exit-tor-relaydoc and are not repeated here.
If any of the above is not true, stop and fix it before bringing up an onion service. A v3 onion address ties cryptographically to the long-term identity key sitting in /var/lib/tor/your-onion/hs_ed25519_secret_key [Tor rend-spec-v3 §1.5] — losing that key means losing the address forever, and exposing it means an attacker can impersonate the service indefinitely. Treat the box accordingly.
Step 1 — install Tor from the upstream repo, not the distro
The Debian-shipped tor package lags upstream by minor versions. For onion-service operators that gap matters: post-2024 ntor v3 handshake performance fixes, vanguards integration improvements, and the consensus-related changes for v3 introduction-point rotation all live in upstream Tor releases. [Tor relay-spec — version semantics]
Add the Tor Project’s apt repo with a keyring file (NOT apt-key, which has been deprecated since Debian 11):
apt update
apt install -y apt-transport-https gnupg ca-certificates wget
# Tor Project signing key — fingerprint pinned in the URL.
wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc \
| gpg --dearmor > /usr/share/keyrings/tor.gpg
cat > /etc/apt/sources.list.d/tor.list <<'EOF'
deb [signed-by=/usr/share/keyrings/tor.gpg] https://deb.torproject.org/torproject.org bookworm main
deb-src [signed-by=/usr/share/keyrings/tor.gpg] https://deb.torproject.org/torproject.org bookworm main
EOF
apt update
apt install -y tor deb.torproject.org-keyring tor-geoipdb
Tor version 0.4.8.13. Tor is running on Linux with Libevent 2.1.12-stable, OpenSSL 3.0.16, Zlib 1.2.13, Liblzma 5.4.1, and Libzstd 1.5.5 N/A.
If the version string starts with 0.4.7. or earlier, the upstream repo line was probably misconfigured — re-check /etc/apt/sources.list.d/tor.list before continuing. Old tor works for an onion service, but you’ll be missing the v3 single-onion-service performance fixes from late 2024.
Step 2 — the application: nginx bound to localhost
The onion service is exposed by Tor as a TCP forward — Tor accepts the rendezvous circuit, then opens a local TCP connection to whatever address you point HiddenServicePort at. The application MUST listen on 127.0.0.1 (or a unix socket) and MUST NOT bind to 0.0.0.0. A clearnet listener on the same VPS defeats the entire purpose of the onion service.
Install nginx and configure a single static-content site bound to localhost:
apt install -y nginx
Drop the following into /etc/nginx/sites-available/onion-default and symlink it into sites-enabled (replace default if it exists):
# /etc/nginx/sites-available/onion-default
# Onion-only site — bind to localhost, never to a public interface.
# The Tor daemon forwards rendezvous traffic to 127.0.0.1:8080.
server {
listen 127.0.0.1:8080 default_server;
listen [::1]:8080 default_server;
server_name _;
# Hide nginx version + the OS hint from Server: header.
server_tokens off;
# Document root — this is what your onion address serves.
root /var/www/onion;
index index.html;
# Disable access logging — operationally we don't need to know who fetched
# what, and any logs we keep create a correlation surface for an attacker
# who later compromises the box. Errors only.
access_log off;
error_log /var/log/nginx/onion-error.log warn;
# Defensive headers. Onion services sit behind Tor's authenticated
// encryption already; HSTS is meaningless (no clearnet TLS), CSP still
// matters because the served HTML can pull resources.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'" always;
# Reject methods other than GET / HEAD — most onion sites are static.
if ($request_method !~ ^(GET|HEAD)$) {
return 405;
}
location / {
try_files $uri $uri/ =404;
}
}
Make sure default_server on 0.0.0.0 is gone — grep -r 'listen.*default_server' /etc/nginx/ should ONLY show the localhost binding above. Then create a minimal index page and reload nginx:
mkdir -p /var/www/onion
cat > /var/www/onion/index.html <<'EOF'
<!doctype html>
<html><head><meta charset="utf-8"><title>onion</title></head>
<body><pre>$ whoami
onion service operational
$ tor --version | head -1
Tor 0.4.8.x
</pre></body></html>
EOF
ln -sf /etc/nginx/sites-available/onion-default /etc/nginx/sites-enabled/onion-default
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
Verify nginx is bound to 127.0.0.1:8080 ONLY:
LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("nginx",pid=1234,fd=6))
LISTEN 0 511 [::1]:8080 [::]:* users:(("nginx",pid=1234,fd=7)) If you see 0.0.0.0:80 or *:443 in the nginx output, the legacy default site is still enabled. Fix it before continuing.
Step 3 — the hardened torrc for an onion-only deployment
This is the canonical onion-service torrc. Every directive has a comment explaining its presence; if you delete a comment, you lose six months from now when you’re trying to remember why SocksPort is 0.
# /etc/tor/torrc — v3 onion service, no relay, no client.
# Replace <YOUR-CONTACT-EMAIL> before starting.
# ---------- identity / contact ---------------------------------------------
# The onion is anonymous to the network, but operationally we want a contact
# address for upstream. Bandwidth/relay-related fields are intentionally
# absent — this box is NOT a relay.
# ---------- onion service definition ---------------------------------------
HiddenServiceDir /var/lib/tor/onion-default/
HiddenServicePort 80 127.0.0.1:8080
# v3 explicit. v2 onions are gone from the network; Tor will warn if you
# omit this and silently default behaviour drift is a class of bug we
# don't want.
HiddenServiceVersion 3
# Single-onion mode is for non-anonymous origin (the SERVICE doesn't need
# anonymity, only the visitors do). Enables a 3-hop instead of 6-hop circuit
# and roughly halves latency. Use ONLY if the operator's anonymity is NOT
# part of the threat model (e.g. publicly-attributed news org). Default off.
# HiddenServiceSingleHopMode 1
# HiddenServiceNonAnonymousMode 1
# ---------- non-relay, non-exit, non-client --------------------------------
SocksPort 0
ClientUseIPv4 0
ClientUseIPv6 0
ExitRelay 0
ExitPolicy reject *:*
PublishServerDescriptor 0
ORPort 0
DirPort 0
# ---------- logging --------------------------------------------------------
# notice-level only; INFO / DEBUG leak operational metadata that, if the
# box is ever subpoenaed, becomes evidence we did not need to retain.
Log notice file /var/log/tor/notice.log
DataDirectory /var/lib/tor
# ---------- safety nets ----------------------------------------------------
# Disable ptrace attachment — ptrace on tor exposes circuit-state secrets.
DisableDebuggerAttachment 1
HardwareAccel 1
# Cap memory in case a misbehaving introduction-point flood starts allocating
# cells faster than they can be processed. 1 GB is generous for a single
# onion service; raise only if the workload demands it.
MaxMemInQueues 1024 MB
Make sure /var/log/tor/ exists and is owned by the debian-tor user (the package creates it on install; a fresh re-install of /etc/tor may have removed the log dir):
mkdir -p /var/log/tor
chown debian-tor:debian-tor /var/log/tor
chmod 0750 /var/log/tor
Restart Tor and tail the journal:
tor[12345]: Tor 0.4.8.13 opening new log file. tor[12345]: Configuration was valid. tor[12345]: Bootstrapped 5% (conn): Connecting to a relay tor[12345]: Bootstrapped 100% (done): Done tor[12345]: Your Tor server's identity key fingerprint is '...' tor[12345]: HSv3: Created hidden service descriptor for /var/lib/tor/onion-default/ tor[12345]: HSv3: Uploaded descriptor for service to N HSDirs.
The two milestone log lines are Bootstrapped 100% (done) and Uploaded descriptor for service to N HSDirs. Without the second one the onion is not yet reachable; usually it appears within 30 seconds of the first one.
Step 4 — read the onion address
The Tor daemon writes the onion address to a file in HiddenServiceDir:
abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567ab.onion
That 56-character base32 string is your service’s permanent address. The hs_ed25519_public_key and hs_ed25519_secret_key files in the same directory are the long-term identity keys; the address is derived from the public key. Back up the secret key OFFLINE (USB drive, paper QR, hardware-backed keystore — operator’s choice) before you do anything else.
ls -la /var/lib/tor/onion-default/
chmod 0700 /var/lib/tor/onion-default/
chown -R debian-tor:debian-tor /var/lib/tor/onion-default/
The directory permissions MUST be 0700 and the owner MUST be debian-tor. If they’re not, the Tor daemon refuses to start and writes a refusal-with-explanation to the log.
Step 5 — vanity address with mkp224o (optional)
Most operators want an onion address that’s at least partly memorable. The mkp224o tool brute-forces v3 onion keypairs whose base32 encoding starts with a chosen prefix. Time-to-find is roughly exponential in prefix length:
| Prefix length | Median search time on 8 modern cores |
|---|---|
| 4 chars | <1 second |
| 6 chars | ~10 seconds |
| 7 chars | ~5 minutes |
| 8 chars | ~3 hours |
| 9 chars | ~4 days |
| 10 chars | ~4 months |
These figures are for mkp224o [mkp224o README — performance numbers] on a single 8-core x86_64 box. Renting GPU time gets you roughly an order of magnitude per generation of hardware; an 8-character prefix is a reasonable upper bound for a couple of dollars of GPU time, anything past 10 characters becomes operationally tedious without a fleet.
Build mkp224o from source (no Debian package as of bookworm):
apt install -y build-essential autoconf libsodium-dev
git clone https://github.com/cathugger/mkp224o.git /opt/mkp224o
cd /opt/mkp224o
./autogen.sh
./configure --enable-amd64-51-30k --enable-donna
make -j"$(nproc)"
Search for an address starting with cnode:
> calculated 8 hashes per second per thread > using 8 threads > waiting for results... > found: cnodexyzabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnop.onion > ./out/cnodexyz...onion/ created
Each result is a directory with hostname, hs_ed25519_public_key, and hs_ed25519_secret_key matching what Tor expects in a HiddenServiceDir. To install: stop Tor, replace the contents of /var/lib/tor/onion-default/, restart.
systemctl stop tor
# Back up the original keys (we may want to revert).
cp -a /var/lib/tor/onion-default /var/lib/tor/onion-default.bak
# Replace.
cp ./out/cnode*/hostname /var/lib/tor/onion-default/hostname
cp ./out/cnode*/hs_ed25519_public_key /var/lib/tor/onion-default/hs_ed25519_public_key
cp ./out/cnode*/hs_ed25519_secret_key /var/lib/tor/onion-default/hs_ed25519_secret_key
chown -R debian-tor:debian-tor /var/lib/tor/onion-default
chmod 0700 /var/lib/tor/onion-default
chmod 0600 /var/lib/tor/onion-default/hs_ed25519_*
systemctl start tor
journalctl -u tor@default -n 20 --no-pager
The new hostname appears in the log within a few seconds. Confirm:
cnodexyzabcdefghijklmnopqrstuvwxyz234567abcdefghijklmnop.onion
Step 6 — sshd lockdown (must land BEFORE the onion is announced)
The single most common operator failure on a fresh hidden service box is leaving sshd listening on the public IP with weakly-rate-limited password auth or with stale RSA-2048 host keys. The full sshd-hardening procedure lives in harden-sshd; the bare minimum to land before publishing the onion address anywhere:
# Edit /etc/ssh/sshd_config — these directives must all be present and uncommented.
Port 22
PermitRootLogin prohibit-password
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
PubkeyAuthentication yes
# Modern KEX / cipher / MAC. The defaults on Debian 12 are fine, but pin them
# explicitly so an upstream change to the default set doesn't move the policy.
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
LoginGraceTime 20
MaxAuthTries 3
MaxSessions 3
ClientAliveInterval 300
ClientAliveCountMax 2
sshd -t && systemctl reload sshd
sshd -t validates the config without restarting; if it errors, do NOT reload — your existing SSH session will survive the broken config but new connections will be rejected, which can cost you the box if there’s no console access.
Step 7 — four post-launch checks
Before announcing the onion address anywhere, work through these four checks. Each one catches a class of misconfiguration that is easy to ship and embarrassing to discover.
Check 1 — no clearnet listener exposes the same content
ss -tlnp | grep -v 127.0.0.1 | grep -v '::1' | grep -v 'tor'
The output should be EMPTY except for sshd on port 22. If nginx, postfix, exim, php-fpm, or any other daemon is listening on 0.0.0.0 or the public IP, fix it before publishing the onion. The CryptoSec Quad of “deanonymisation by side channel” usually starts with a clearnet daemon serving the same content as the onion. [Owenson et al., 'Survey of HS deanonymisation attacks', Tor Metrics 2023]
Check 2 — DataDirectory permissions
namei -l /var/lib/tor/onion-default/hs_ed25519_secret_key
Every component of the path should be drwx------ or drwxr-xr-x, owned by debian-tor from /var/lib/tor downward. The secret key file itself should be -rw------- and debian-tor:debian-tor. If anything in that chain is world-readable, the key is exfiltrable by any local user — including, on shared-tenancy KVM, a misconfigured guest agent.
Check 3 — the onion responds via Tor Browser
Fetch the onion via torsocks from another machine:
HTTP/1.1 200 OK Server: nginx Content-Type: text/html X-Content-Type-Options: nosniff Content-Security-Policy: default-src 'self'; ... <!doctype html> <html>...
If the request times out, the descriptor hasn’t propagated to enough HSDirs yet — wait 60 seconds and retry. If it returns a 5xx, nginx is misconfigured; check /var/log/nginx/onion-error.log. If it returns 200 from the wrong content, you’re hitting a stale cached descriptor — torsocks curl --request HEAD is the lightweight retry.
Check 4 — descriptor publication health
grep -E 'HSv3|hidden service' /var/log/tor/notice.log | tail -20
The expected pattern is one Created hidden service descriptor line per descriptor regeneration (every 60 minutes by default in v3) and one Uploaded descriptor for service per upload (typically 4-8 HSDirs per descriptor). If you see Failed to upload or Not enough HSDirs available, the box has connectivity issues to the directory authorities — check the upstream firewall.
Step 8 — backup the identity key, OFFLINE
The single most catastrophic operational failure for a hidden service is losing the hs_ed25519_secret_key. If it’s gone, the onion address is gone, permanently — there is no recovery, no key escrow, no Tor Project support. Conversely, anyone who has a copy of the secret key can impersonate your onion forever.
The minimal backup procedure:
# On the VPS — emit the key as ASCII-armoured base64.
base64 /var/lib/tor/onion-default/hs_ed25519_secret_key > /tmp/onion-key.b64
sha256sum /tmp/onion-key.b64
# On a trusted local machine — pull the key over scp, then erase from /tmp.
scp root@onion-01:/tmp/onion-key.b64 ./onion-key.b64
ssh root@onion-01 'shred -uvz /tmp/onion-key.b64'
# Locally — verify the checksum, then encrypt with age (or gpg).
sha256sum onion-key.b64
age -p onion-key.b64 > onion-key.b64.age
shred -uvz onion-key.b64
The encrypted blob goes to two physical locations under operator control (a USB stick in a safe, an encrypted drive at a trusted location). DO NOT send the key to a cloud backup, an email account, or any third-party service. The threat model for an onion service includes “the cloud backup provider is subpoenaed”.
Closing — what to do next
The onion is operational. The next things worth doing, in order of operational benefit:
- Read
harden-sshdand apply the full sshd hardening procedure (the minimum landed in step 6 is just the bare floor). - Read
kernel-hardening-checklistand apply the sysctl + AppArmor changes — the brand baseline already covers most of this, but the doc lists the remaining hand-tunes. - If the onion serves dynamic content (anything beyond static HTML), front it with a separate process boundary — a unix-socket-bound application server, namespaced via systemd. The nginx-only static config in this doc is the simplest case; anything beyond it needs its own threat-model pass.
- Subscribe the operator’s email to the
tor-relaysmailing list and to the Tor Project’s security-announce list — onion-service-affecting CVEs are rare but they happen, and you’ll want notice within hours rather than weeks.
The onion address now lives in /var/lib/tor/onion-default/hostname. Announce it where you intended to announce it. Don’t announce it in places that index onion addresses for search — the brand register is “operators announce to operators”, not “publish-and-pray”.
// FREQUENTLY-ASKED
$ faq -p provision-tor-hidden-service
Q. Do I need a Tor relay running on the same VPS as my hidden service?
A. No. A v3 hidden service operates over the public Tor network using whichever guard relays Tor selects automatically; no local relay is required. Co-locating a hidden service with a non-exit middle relay on the same VPS does not improve the hidden service's anonymity (it may marginally help the network) and adds attack surface. Treat them as separate workloads.
Q. Why does XMRHost disable the clearnet sshd listener on Tor plans?
A. Co-hosting a clearnet sshd with a Tor hidden service creates a traffic-correlation attack against the service: an adversary that knows the .onion can probe the server's clearnet IP for sshd uptime + load + traffic patterns, and correlate. XMRHost's tor-1+ tiers reach sshd via onion v3 onion-auth (rend-spec-v3 §G) — no clearnet listener exists.
Q. What is mkp224o and is it required?
A. mkp224o is a vanity .onion address generator for Tor v3 service identities — it brute-forces Ed25519 keypairs whose corresponding .onion address starts with a chosen prefix. XMRHost includes mkp224o (capped at 8 character prefix) on tor-1+ plans. Vanity addresses are convenience, not security: a typed-out .onion is hard to verify regardless of prefix, so the rend-spec-v3 recommended onion-bookmark workflow remains the actual mitigation for typo-squatting.
// END OF DOC
$ cd /docs # back to the manual