What an onion service gives you
A v3 onion service is reachable only through the Tor network. Visitors never learn the server’s IP, the server never learns the visitor’s IP, the address itself is the public key (so no CA and no DNS), and nothing needs to be open on the public interface. That makes it a good fit for a VPS you paid for without an account: the hosting relationship is the only link between you and the box, and the onion address does not expose even that.
This guide puts nginx behind tor on one small server. The same pattern works for any TCP service: swap the nginx port for SSH, Gitea, a Nextcloud container, or a Bitcoin node.
Prepare the server
Everything below assumes a fresh Ubuntu 24.04 image and a root shell (prefix sudo if you created a user). Patch first, then lock down SSH and the firewall before anything else is listening.
apt update && apt full-upgrade -y
apt install -y ufw unattended-upgrades curl gnupg
# SSH: keys only. Put your public key in /root/.ssh/authorized_keys first and
# confirm you can log in with it before disabling passwords.
cat > /etc/ssh/sshd_config.d/00-keys-only.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
EOF
sshd -t && systemctl restart ssh
# Firewall: deny everything inbound except SSH for now.
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw --force enableInstall tor and nginx
Ubuntu 24.04 ships Tor 0.4.8, which supports v3 services, intro-point DoS defense and proof-of-work. If you want the newest release, add the Tor Project apt repository; for a basic site the Ubuntu package is fine.
apt install -y tor nginx
tor --version
systemctl enable --now tor nginx
# The default nginx site listens on 0.0.0.0:80. Remove it; nothing should listen publicly.
rm -f /etc/nginx/sites-enabled/defaultConfigure nginx on localhost only
Bind nginx to 127.0.0.1 on a port only tor will talk to. Turn off version strings and anything that could put the server’s clearnet identity into a response. Access logs are low value here because every request arrives from 127.0.0.1 anyway; disable them or keep them without timestamps if you need debugging.
# /etc/nginx/sites-available/onion
server {
listen 127.0.0.1:8080;
server_name _;
root /var/www/onion;
index index.html;
server_tokens off;
access_log off;
error_log /var/log/nginx/onion-error.log warn;
# Do not let nginx redirect to its own hostname/IP.
absolute_redirect off;
port_in_redirect off;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
location / {
try_files $uri $uri/ =404;
}
}install -d -m 0755 /var/www/onion
echo '<h1>It works over Tor.</h1>' > /var/www/onion/index.html
ln -s /etc/nginx/sites-available/onion /etc/nginx/sites-enabled/onion
nginx -t && systemctl reload nginx
ss -ltnp | grep nginx # must show 127.0.0.1:8080 onlyCreate the hidden service in torrc
cat >> /etc/tor/torrc <<'EOF'
# This box only hosts a service; no local SOCKS proxy needed.
SocksPort 0
HiddenServiceDir /var/lib/tor/onion_site/
HiddenServiceVersion 3
HiddenServicePort 80 127.0.0.1:8080
# Intro-point rate limiting (pushed to introduction points via the descriptor).
HiddenServiceEnableIntroDoSDefense 1
HiddenServiceEnableIntroDoSRatePerSec 25
HiddenServiceEnableIntroDoSBurstPerSec 200
# Proof-of-work: clients solve a puzzle when the service is under load.
HiddenServicePoWDefensesEnabled 1
HiddenServicePoWQueueRate 250
HiddenServicePoWQueueBurst 2500
EOF
systemctl restart tor
cat /var/lib/tor/onion_site/hostnameTor creates the directory, generates an ed25519 keypair and writes the 56-character address to hostname. Open it in Tor Browser; the first descriptor upload can take a minute. If tor refuses to start, check journalctl -u tor: the PoW options require a build with PoW support (Ubuntu’s and the Tor Project’s both have it), and the directory must be owned by debian-tor with mode 700.
Advertise it with Onion-Location
If you also run a clearnet site, Tor Browser will offer visitors the onion version when the clearnet site sends an Onion-Location header. It must be served over HTTPS, and the value must be an http:// onion URL. Add this to the clearnet site’s server block, not the onion one.
# In the clearnet (HTTPS) server block:
add_header Onion-Location http://YOUR56CHARADDRESS.onion$request_uri always;Alternatively, a <meta http-equiv="onion-location" content="http://....onion"> tag in the page head does the same job if you cannot change headers.
Back up the private key
The onion address is derived from hs_ed25519_secret_key. Lose that file and the address is gone forever; leak it and anyone can impersonate your site. Back it up off the server, encrypted, as soon as it exists.
# On the server:
tar czf - -C /var/lib/tor onion_site | gpg --symmetric --cipher-algo AES256 -o /root/onion_site.tgz.gpg
# From your own machine:
scp [email protected]:/root/onion_site.tgz.gpg ~/backups/
ssh [email protected] shred -u /root/onion_site.tgz.gpg
# Restoring on a new server, after installing tor:
gpg -d onion_site.tgz.gpg | tar xzf - -C /var/lib/tor
chown -R debian-tor:debian-tor /var/lib/tor/onion_site
chmod 700 /var/lib/tor/onion_site
systemctl restart torOptional: a vanity address with mkp224o
mkp224o brute-forces ed25519 keys until the address starts with a chosen prefix. Each extra base32 character multiplies the work by 32: five characters takes minutes on a couple of cores, seven takes days. Generate on your own machine or the VPS itself, then drop the files into the HiddenServiceDir.
apt install -y git gcc libc6-dev libsodium-dev make autoconf
git clone --depth 1 https://github.com/cathugger/mkp224o
cd mkp224o && ./autogen.sh && ./configure && make
./mkp224o -d keys -n 1 -t $(nproc) mysite # stops after 1 match
systemctl stop tor
rm -rf /var/lib/tor/onion_site
cp -r keys/mysite*.onion /var/lib/tor/onion_site
chown -R debian-tor:debian-tor /var/lib/tor/onion_site
chmod 700 /var/lib/tor/onion_site
systemctl start tor
cat /var/lib/tor/onion_site/hostnameOperational notes
- ufw should still only allow OpenSSH. Confirm with ufw status and ss -ltnp that 8080 is bound to 127.0.0.1 and nothing else is listening.
- Keep application-level identifiers out of the site: no clearnet hostnames in templates, no third-party assets, no analytics. The onion layer hides the IP, not your HTML.
- For multiple services, add more HiddenServiceDir blocks (one per address) or more HiddenServicePort lines under one directory (same address, different ports).
- HiddenServiceSingleHopMode trades your server-side anonymity for lower latency. Do not enable it unless the server location is already public.
- Unattended-upgrades will restart tor on security updates; the key persists on disk, so the address does not change.
Which plan to pick
A static or lightly dynamic onion site needs almost nothing: the smallest plan (2 vCPU, 4 GB RAM, 40 GB NVMe from about $10/mo) is more than enough and leaves room to run the hidden service alongside something heavier later. Region matters little for an onion service since every visitor arrives through the Tor network; pick whichever jurisdiction you prefer. Configure a server, pay in Bitcoin or another coin, and the root credentials arrive via magic link without an account.