Host an .onion site on your VPS
Run a v3 onion service on Ubuntu with tor and nginx bound to loopback, then close the leaks that link the address back to your public IP.
What you are building
An onion site is a normal web server that only answers over the Tor network. Install tor, add two lines to /etc/tor/torrc, read the address tor writes for you, then bind nginx to 127.0.0.1 so nothing answers on the public IP. The install part takes ten minutes. The rest of this guide is the leak list, because the usual way an onion site fails is that its own configuration points straight back at the operator.
Tor started as "the onion router", and an onion service is a service you can only reach through it. A version 3 address is 56 characters followed by .onion, and those characters are the service's ed25519 public key plus a checksum and a version byte, encoded in base32. Version 2 addresses (16 characters) were removed from the network in 2021, so anything you generate today is v3. The address is the key, which has two consequences. The connection is encrypted and authenticated end to end with no certificate authority involved, and losing the key file means losing the address for good.
Your server never accepts an inbound connection. Tor picks a few relays as introduction points, uploads a signed descriptor to the directory servers, and meets each visitor at a rendezvous relay the visitor chose. Every one of those connections is outbound from your box. There is no port to open and no DNS record to publish.
Install tor from the Tor Project repository
Ubuntu ships a tor package in universe, but it stays near the version that was current when the release froze. The Tor Project's own repository tracks the current stable release, which is what you want for the piece of software that decides whether your address stays yours.
sudo apt update
sudo apt install -y apt-transport-https gnupg wget
KEYURL=https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc
wget -qO- "$KEYURL" | gpg --dearmor | sudo tee /usr/share/keyrings/deb.torproject.org-keyring.gpg >/dev/nullThe repository entry uses the deb822 format, and Suites must be your Ubuntu codename. Read it from /etc/os-release instead of typing it, because a wrong codename gives you a repository that resolves fine and then has no packages for your release.
. /etc/os-release
sudo tee /etc/apt/sources.list.d/tor.sources >/dev/null <<EOF
Types: deb deb-src
URIs: https://deb.torproject.org/torproject.org/
Suites: $VERSION_CODENAME
Components: main
Signed-By: /usr/share/keyrings/deb.torproject.org-keyring.gpg
EOF
sudo apt update
sudo apt install -y tor deb.torproject.org-keyringThe deb.torproject.org-keyring package keeps the signing key current, so a key rotation does not break apt update a year from now. Check that tor started and reached the network:
tor --version
sudo journalctl -u tor@default -n 20The journal should end with Bootstrapped 100% (done): Done. A tor stuck at Bootstrapped 10% has no outbound path, so check your provider's network firewall and your own egress rules: sudo ufw status verbose should show allow (outgoing) as the default.
Two names matter from here on. The package runs tor as the debian-tor user, and the running unit is tor@default.service, since tor.service on Debian and Ubuntu is a wrapper around the instance. Ask for status and logs by the instance name and you always get the real process.
Configure the onion service in torrc
Append two lines to /etc/tor/torrc:
HiddenServiceDir /var/lib/tor/onion_site/
HiddenServicePort 80 127.0.0.1:8080HiddenServiceDir is where tor keeps the keys and the address for this service. Do not create it yourself. Tor creates it on start with the owner and the mode it needs, and a directory you made as root produces the first failure in the failure list below.
HiddenServicePort has two halves, and mixing them up is the common first mistake. The first number is the port a visitor connects to inside the tunnel, so 80 is what people expect and there is no reason to change it. The second half is the local address tor forwards that traffic to. A bare HiddenServicePort 80 forwards to 127.0.0.1:80, so writing the address out and using a high port keeps the onion vhost clear of anything already listening on 80.
sudo systemctl restart tor@default
sudo ls -l /var/lib/tor/onion_site/The listing should hold hostname, hs_ed25519_public_key, hs_ed25519_secret_key and an empty authorized_clients directory.
Read your .onion address
sudo cat /var/lib/tor/onion_site/hostnameOne line comes back: 56 base32 characters and .onion. That string is the site's whole identity. Nobody assigns it, nobody can transfer it, and nobody can take it away while you hold the key file. Copy it now, because every config below needs it. The rest of this guide writes it as <your-address>.onion.
Serve the site from nginx bound to 127.0.0.1
sudo apt install -y nginx
sudo install -d -m 755 /srv/onionWrite /etc/nginx/sites-available/onion:
server {
listen 127.0.0.1:8080;
server_name <your-address>.onion;
root /srv/onion;
index index.html;
server_tokens off;
etag off;
access_log off;
error_log /var/log/nginx/onion.error.log error;
}echo '<h1>hello from the onion</h1>' | sudo tee /srv/onion/index.html
sudo ln -s /etc/nginx/sites-available/onion /etc/nginx/sites-enabled/onion
sudo nginx -t
sudo systemctl reload nginxNow prove two things from the server. The first is that nginx answers for the onion name, which is the exact Host header tor will send:
curl -s -H 'Host: <your-address>.onion' http://127.0.0.1:8080/The second is that it answers there and nowhere else:
sudo ss -tlnp | grep 8080The address column must read 127.0.0.1:8080. If it reads 0.0.0.0:8080 or *:8080, your onion site is also on the public internet, which is the first item in the leak list. A listen 8080; line with no address binds every interface, and that is the default.
Open the address in Tor Browser. The first load takes a few seconds while the client fetches your descriptor and builds a rendezvous circuit.
The Tor Project's own documentation prefers a unix socket to a loopback port: HiddenServicePort 80 unix:/var/run/tor/onion_site.sock, with nginx listening on that path. A socket cannot be reached from another host at all, even if the box later grows a second interface. The cost is file permissions, since nginx creates the socket and tor connects to it as debian-tor, so the two users have to agree on the directory. Loopback with a verified ss output is easier to get right, and it is what the rest of this guide assumes.
With the site on loopback, the box needs no inbound rule for it at all. Keep 22 open for yourself and deny the rest (the ufw defaults worth setting on a VPS). Remember that a firewall does not undo a service that binds 0.0.0.0, it only filters packets that reach the firewall. Containers make this sharper, because publishing a Docker port writes iptables rules ahead of ufw, so -p 8080:80 puts your onion backend on the public IP while ufw still reports the port as denied. Publish container ports as -p 127.0.0.1:8080:80.
The leaks that de-anonymise an onion site
Tor hides where the server is. Nothing in Tor hides what the server says. Every item below is something your own stack publishes.
The same site answering on your public IP
This is the one that catches people. Scanners index the HTTP response of every routable address continuously, and those results are public and searchable. Serve the same page on your public IP and on your onion address, and joining them is one query: same title, same favicon hash, same ETag, same header order. The listen 127.0.0.1:8080; line above is the fix. Verify it from a different machine, not from the server:
curl -sv --max-time 5 http://<your-public-ip>:8080/Connection refused or a timeout is the correct result. Any HTML means the site is public. If the box also runs a clearnet site, give that vhost its own root and keep an explicit default_server block on the public listener, so an unmatched Host header can never fall through to the onion vhost.
Version banners
curl -sI http://127.0.0.1:8080/ | grep -i '^server'A default nginx answers Server: nginx/1.24.0. That version string, together with the exact order of the other headers, is a fingerprint that matches your onion against your clearnet host. server_tokens off; reduces it to Server: nginx. It does not remove the header, and nginx has no built-in directive that does, so the headers-more module is the usual answer if you want it gone. PHP adds X-Powered-By until you set expose_php = Off. etag off; belongs in the same list, because nginx builds the ETag from a file's modification time and size, so the same files copied to two servers hand out the same ETag on both.
Absolute URLs pointing at your clearnet domain
A rel="canonical" tag, an Open Graph og:url, an RSS feed, a sitemap, a password reset email, a hardcoded logo URL. Any one of them names the clearnet site inside a page served over the onion. Use root-relative paths such as /static/logo.svg, and let the application read its base URL from the request host instead of a constant. Redirects are the same bug in another place: return 301 https://example.com$request_uri; in a catch-all block sends the onion visitor to your real domain, and the Location header hands them the answer directly.
A TLS certificate shared with the clearnet site
An onion address authenticates itself, because the address is the public key, so http:// over an onion connection is already encrypted end to end and Tor Browser treats it as a secure context. Installing your existing certificate on the onion vhost publishes the link between the two, because every publicly trusted certificate is recorded in Certificate Transparency logs, and those logs are public, permanent and searchable by name. Keep Let's Encrypt certificates on the clearnet vhost and leave the onion vhost on plain HTTP.
Third-party fonts and analytics
A font from a CDN (content delivery network), or an analytics script. The visitor's browser fetches each one directly, so the third party learns that someone loaded your page and usually which page, and Tor Browser's stricter security levels block the request anyway, leaving a broken layout. Self-host every asset the page needs.
Host header mismatch
If server_name does not match the Host header tor sends, nginx falls back to the default server for that listen address. On a box with one vhost this is invisible, because the only server block is also the default. Add a clearnet vhost later and onion requests can start landing on it, complete with its canonical tags and its redirects. Re-run the curl -H 'Host: ...' check after every nginx change, and grep the result for your real domain:
curl -s -H 'Host: <your-address>.onion' http://127.0.0.1:8080/ | grep -o 'https\?://[^"]*' | sort -uKnowing which process owns which socket is most of this job (how ports and listening sockets work on Linux).
What is left in the logs
Every request arrives from 127.0.0.1, so nginx has no visitor address to record and access_log off; costs you nothing. The application above it is a different matter, since an order, an email address or an uploaded file's metadata is yours to handle. Your own habits count too: administering the box over an unhardened login sits outside anything Tor protects, so treat SSH hardening on the same VPS as part of this build.
Back up the private key, because it is the address
/var/lib/tor/onion_site/hs_ed25519_secret_key is the service. There is no registrar and no recovery. Lose it and the address is gone. Copy it, and whoever holds the copy can serve their own content at your address, with no way for you to revoke anything.
sudo systemctl stop tor@default
sudo tar -C /var/lib/tor -czf onion-keys.tgz onion_site
sudo chmod 600 onion-keys.tgz
sudo systemctl start tor@defaultEncrypt that archive (gpg -c onion-keys.tgz) and move it off the server. Restoring on a new VPS is the archive plus the ownership tor expects:
sudo systemctl stop tor@default
sudo tar -C /var/lib/tor -xzf onion-keys.tgz
sudo chown -R debian-tor:debian-tor /var/lib/tor/onion_site
sudo chmod 700 /var/lib/tor/onion_site
sudo systemctl start tor@default
sudo cat /var/lib/tor/onion_site/hostnameThe same address comes back on the new hardware, a minute or two after tor republishes the descriptor. That is the whole migration: no DNS change and no certificate reissue.
Onion-Location, when the site also lives on the clearnet
If the onion is a convenience rather than a secret, advertise it from the clearnet vhost:
add_header Onion-Location http://<your-address>.onion$request_uri;Tor Browser then shows a .onion available button in the address bar and offers the switch. The header is only honoured when the clearnet page is served over HTTPS and the value is a valid onion URL.
One nginx rule bites here. add_header directives are inherited by a location block only when that block declares none of its own, so a location with its own add_header silently drops Onion-Location. Repeat it there, or keep all response headers in one place. Publishing this header deliberately joins the two sites, which is correct for a mirror and wrong for anything meant to stay unlinked.
Vanity addresses
mkp224o generates key pairs until one produces an address that starts with the prefix you asked for. It is a brute force search, so there is nothing to configure beyond the prefix and how long you are willing to wait.
sudo apt install -y git gcc libc6-dev libsodium-dev make autoconf
git clone https://github.com/cathugger/mkp224o
cd mkp224o
./autogen.sh
./configure --enable-amd64-51-30k
make
./mkp224o -d onionkeys blogEach hit lands in onionkeys/<address>.onion/ holding hostname and hs_ed25519_secret_key. Install one by stopping tor, copying that directory over your HiddenServiceDir, then applying the same chown and chmod 700 as the restore above.
Prefix length is the whole cost. The address is base32, so every extra character you demand multiplies the expected number of keys by 32. A short prefix finishes on a laptop. A long one does not finish on anything you own. A vanity prefix also teaches readers to recognise the first few characters instead of the whole address, and that is the habit phishing copies of onion sites are built on.
Failure modes, with the strings you will see
No hostname file after the restart. Tor did not start, or it refused the directory. sudo journalctl -u tor@default -n 50 names it:
/var/lib/tor/onion_site/ is not owned by this user (debian-tor, 108) but by root (0). Perhaps you are running Tor as the wrong user?That is what a directory created by hand looks like. Fix the ownership and the mode, or delete the directory and let tor build it.
Tor Browser shows Onionsite Not Found (0xF0). The client could not fetch a descriptor, so as far as the network is concerned nothing is published at that address. Confirm tor is running and bootstrapped, compare the address you typed against sudo cat /var/lib/tor/onion_site/hostname character by character, then check the clock. Tor needs accurate time to publish and validate descriptors, and timedatectl should report System clock synchronized: yes.
The address resolves but the page never loads. Tor completed the rendezvous and then failed on the last hop, from tor to nginx, and that hop is local so the tor log stays quiet. Run curl -sI http://127.0.0.1:8080/ on the server. Connection refused means nginx is down or listening on a different address than HiddenServicePort points at.
The page loads and every link goes to your real domain. Absolute URLs in the templates. Run the grep -o 'https\?://[^"]*' check above and fix what it prints before you share the address anywhere.
It works, then stops after a reboot. Reboot the box once on purpose before you rely on the site, then run sudo systemctl status tor@default and sudo systemctl status nginx. A service someone started by hand looks identical to an enabled one until the machine restarts.
FAQ
Do I need to open a port in my firewall for a Tor onion service?
No. The tor daemon makes only outbound connections, to the directory servers, to its introduction points and to each rendezvous relay, so no inbound rule is needed and the web server itself listens on 127.0.0.1. Keep ufw at default deny for incoming traffic with SSH allowed. The same property means an onion service works from a machine behind NAT (network address translation) with no public IP at all.
Why can I not reach my .onion address in Tor Browser?
Work outward from the server. sudo journalctl -u tor@default -n 50 should show Bootstrapped 100% (done): Done, then curl -sI http://127.0.0.1:8080/ on the server should return a status line, then compare the address you typed against the hostname file, since one wrong character is simply a different service. Onionsite Not Found (0xF0) means no descriptor was found for the address, which usually means tor is not running or the system clock is wrong.
Can I move my onion site to a new server and keep the same address?
Yes. The address is derived from hs_ed25519_secret_key, so copy the whole HiddenServiceDir to the new box, set it to debian-tor ownership and mode 700, and start tor. The address is live again once the descriptor is republished, and there is no DNS record to update. Lose that file and the address is unrecoverable, so back it up encrypted and off the server the day you create it.
Does an onion site need an HTTPS certificate?
No. The 56-character address is the service's public key, so the connection is already encrypted and authenticated end to end, and Tor Browser treats http:// on a .onion name as a secure context. Reusing your clearnet certificate on the onion vhost is worse than doing nothing, because Certificate Transparency logs are public and permanently record which names share a certificate. The only reason to buy a certificate for a .onion name is brand assurance from a CA that issues them, and that link is public by design.