Hardened Onion Service on Ubuntu
Created September 12, 2026 • Last updated September 12, 2026 • 12 min read • 259 views
This guide builds a hardened onion service from the host baseline to leak testing before you publish. It fits two cases. In the first, the server location stays secret and every path out runs through Tor. In the second, you mirror a clearnet site over an onion for censorship resistance and visitor privacy. Where the two cases split, the text says so.
Threat model for a hardened onion service
You protect three things: the server location, the visitors, and the integrity of the content. A hardened onion service leaks in predictable ways. A virtual host answers on the raw IP. The application makes an outbound request that exposes the host. An error page prints an internal hostname. A shared fingerprint links the onion and the clearnet site.
One principle drives the whole build. The box must have no network path except through Tor, and nothing in the HTTP response may differ between reachable over the onion and probed directly.
Warning
The single most reliable protection is an egress lock. If the web user and the PHP user cannot open outbound connections, the application cannot leak the host, no matter what the code does. You add it near the end, once everything is installed.
Host baseline
Start from a minimal server install of a current Debian or Ubuntu release. Use SSH keys only and disable password and root login. Keep the package set small. Every extra service is another listener to account for.
Update the system and install a time sync daemon first. Tor descriptors and the network consensus depend on a correct clock. Each other service is installed in its own section below, so nothing outbound is blocked while you build.
Install Tor from the project repository
The distribution package often trails upstream and can miss the current denial of service defenses. Add the Tor Project repository with a signed keyring and pull tor from there. The commands below resolve your release codename and architecture on their own, so they stay valid across upgrades.
Explain command
| --dearmor | Converts the ASCII-armored signing key into the binary keyring format apt expects. |
Confirm the build ships the proof of work module. The distribution package sometimes omits it, which silently disables the defense you set below.
pow: yes
Configure the onion service
Bind the service to a loopback port that nginx serves. Nothing listens on the public interface, so a port scan of the host finds no web server. Enable the introduction point rate limiting and the proof of work defense, which prioritize real clients when the service comes under load. Append the block below to /etc/tor/torrc.
Generating a memorable address and running several onions from one daemon each get their own page. See generating a vanity onion address and running multiple onion services on one host.
HiddenServiceDir /var/lib/tor/site/
HiddenServicePort 80 127.0.0.1:8080
HiddenServiceEnableIntroDoSDefense 1
HiddenServiceEnableIntroDoSRatePerSec 25
HiddenServiceEnableIntroDoSBurstPerSec 200
HiddenServicePoWDefensesEnabled 1
HiddenServicePoWQueueRate 250
HiddenServicePoWQueueBurst 2500
HiddenServiceMaxStreams 100
HiddenServiceMaxStreamsCloseCircuit 1
Important
The address is the key. The secret in /var/lib/tor/site/hs_ed25519_secret_key defines your onion, and there is no rotation. Lose it and the address is gone for good. Leak it and someone else can run your address. Back it up offline and never commit it to version control.
Single hop mode is a trap for anonymous services
Tor offers a non-anonymous single hop mode that shortens the circuit and speeds up the service. It also reveals the server location. Use it only for a clearnet mirror where the host is already public. Never set it on a service whose location must stay secret.
Tor creates the service directory on restart with the correct owner and permissions. Read the generated address from the hostname file.
ahxaebv2jyncy5u3xn7anbsgdamowr6mhcioyiiyblxec4ud5geaiy5d.onion
Application user and web root
Run the site under its own account, separate from the nginx worker user. Give the files to that account and share read access with the nginx group. Directories get 750 and files get 640, so no other local user can read the tree. Create a static error page that names nothing and a log directory the pool can write to.
Service unavailable.
Explain command
| --system | Creates a service account with no login and a system UID, not a human user. |
| --user-group | Creates a matching group so the pool user and group both exist. |
Web server: Nginx over Apache
Nginx wins here on attack surface. There is no per-directory override file, no status handler that ships enabled, and no inode based entity tag that can differ between hosts. You also suppress absolute redirects in one line, which stops the server from emitting a hostname you did not intend.
Install nginx, then serve the onion from the loopback port only. Turn off the version banner, directory listing, absolute redirects, and the ETag. The ETag encodes file modification time and size, so a clearnet mirror of the same files would carry identical values and link the two sites. Send every error to a static page that names nothing.
Save this as /etc/nginx/sites-available/onion. The security headers sit inside the server block so every response carries them.
server {
listen 127.0.0.1:8080;
server_name _;
root /var/www/onion;
index index.php index.html;
server_tokens off;
absolute_redirect off;
port_in_redirect off;
autoindex off;
access_log off;
etag off;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/onion.sock;
fastcgi_intercept_errors on;
}
location ~ /\. {
deny all;
}
error_page 403 404 /error.html;
error_page 500 502 503 504 /error.html;
location = /error.html {
internal;
}
}
Watch the header inheritance rule. If a location block sets its own add_header, nginx drops the server level headers inside that block, so repeat them where you override. Enable the site, drop the default, test the config, and reload.
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful
Explain command
| -t | Tests the configuration and refuses to reload on a syntax error. |
For a clearnet mirror, add a separate server block for the public site and a default server that drops any request with an unexpected host. That stops the server from answering scans aimed at the raw IP with a real page.
server {
listen 80 default_server;
server_name _;
return 444;
}
If you must run Apache
Apache is fine when a module you need only exists there. Trim the defaults that talk too much: hide the version, drop the signature, disable the inode entity tag, and remove the trace method.
ServerTokens Prod
ServerSignature Off
FileETag None
UseCanonicalName On
TraceEnable Off
PHP-FPM isolation
Install PHP-FPM and give the site its own pool under the www-onion user. The pool file and the service name both carry the PHP version, so find the pool directory first.
www.conf
Talk to the pool over a unix socket owned by the nginx user. Hide the version, silence errors to the client, and confine the process to its own tree with open_basedir. Save this as onion.conf in that pool directory, for example /etc/php/8.x/fpm/pool.d/onion.conf.
[onion]
user = www-onion
group = www-onion
listen = /run/php/onion.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
php_admin_value[expose_php] = Off
php_admin_flag[display_errors] = Off
php_admin_value[error_log] = /var/log/php/onion-error.log
php_admin_value[open_basedir] = /var/www/onion:/tmp
Restart the pool. The command below picks the highest installed PHP version on its own, so it survives an upgrade.
Warning
Only disable PHP functions the application does not use. Blindly disabling curl or the process functions breaks many applications. Rely on the egress rule you add next as the hard boundary and disable functions as a second layer.
Egress lockdown
With the stack installed, lock the network down. Set a default drop policy on input and output. Allow return traffic, loopback, and SSH inbound. On output, allow only the tor daemon to open new connections. The web user and the PHP user inherit the drop, so a compromised application cannot reach the internet directly. For an anonymous build, drop the SSH rule and reach it over its own onion instead, as covered in SSH over an onion service.
Apply this only after the packages are in place. The output drop cuts clearnet access for the package tool, so run later updates over Tor. Outbound calls from the application are the classic deanonymization path, and this rule stops them at the kernel. If the code needs a specific outbound call, send it through the local Tor SOCKS port rather than opening the firewall. Install nftables and write the rules to /etc/nftables.conf.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
# SSH. For an anonymous service, remove this and reach SSH over its own onion.
tcp dport 22 accept
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy drop;
ct state established,related accept
oif "lo" accept
# Only the tor daemon may open new outbound connections.
meta skuid "debian-tor" accept
# Tor needs an accurate clock. Scope this to your timesync user where you can.
udp dport 123 accept
}
}
The output drop now blocks the package tool over clearnet. Do not install an apt transport package for this, because that download fails behind the lock. Point apt at the local Tor SOCKS port instead, which apt speaks on its own. Write the proxy to /etc/apt/apt.conf.d/99tor.
Acquire::http::Proxy "socks5h://127.0.0.1:9050";
Acquire::https::Proxy "socks5h://127.0.0.1:9050";
The socks5h scheme resolves names through Tor, so it survives the DNS block. For a stronger setup, switch the sources to onion mirrors. The Tor Project serves one at apow7mjfryruh65chtdydfmqfpj5btws7nbocgtaovhvezgccyjazpqd.onion and Debian at 2s4yqjx5ul6okpp3f2gaunr2syex5jgbfpfvhxxbbjwnrsvbk5v3qbid.onion. The first apt update can time out while a circuit builds, so run it again.
Verify and leak-test before going live
A hardened onion service must return the same result whether reached over Tor or probed directly. Confirm that no web listener sits on the public interface. The tor SOCKS port and nginx both bind to loopback. The only port on a public address is SSH, and that leaves for an anonymous build.
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp LISTEN 0 4096 127.0.0.1:9050 0.0.0.0:* users:(("tor",pid=812,fd=6))
tcp LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("nginx",pid=901,fd=6))
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=744,fd=3))
From a different host, scan the public IP and try to reach the site with the onion host header. The scan shows only SSH. The request times out, because the firewall drops traffic to the web port and nginx never listens on the public interface.
Nmap scan report for $SERVER_IP Host is up. Not shown: 65534 filtered tcp ports (no-response) PORT STATE SERVICE 22/tcp open ssh curl: (28) Connection timed out after 5001 milliseconds
Explain command
| -Pn | Skips host discovery and scans even when the host does not answer pings. |
| -p- | Scans all 65535 TCP ports rather than the default top ports. |
Reach the service over Tor to confirm it answers on the onion. Run this from a machine with tor and torsocks installed. The version banner stays hidden, so the server line shows the name only.
HTTP/1.1 200 OK Server: nginx Date: Sat, 12 Sep 2026 12:00:00 GMT Content-Type: text/html Connection: keep-alive
Explain command
| -I | Fetches headers only, which is enough to confirm the service responds. |
Prove the egress lock. Run outbound requests as the web user and confirm both fail. A hostname dies at DNS first, since the resolver cannot reach a nameserver. A raw IP shows the connection itself time out. If either call returns a page, the rule is wrong and the application can leak the host.
curl: (6) Could not resolve host: example.com curl: (28) Connection timed out after 10001 milliseconds
Explain command
| -m 10 | Caps the request at ten seconds so a blocked call fails fast instead of hanging. |
Reaching the service over Tor already proves the descriptor reached the network, so trust that over the log. Tor records the upload only at info level, not at the default notice level. As a last pass, grep the configuration and web root for anything that names the clearnet domain or the host IP. For a clearnet mirror, an absolute URL back to the public site is the most common leak.
Operations
Back up the service key offline and test a restore on a throwaway host. Watch upstream Tor releases, since a major version can deprecate a torrc directive you rely on. Monitor reachability from your own Tor client rather than trusting silence.
Under a flood, raise the proof of work effort by lowering the queue rate and burst, and cap concurrent streams per circuit. That shifts cost onto the attacker while real visitors still get through.
From here, pick a readable address with mkp224o, run several onions from one daemon, and advertise the onion to clearnet visitors with an Onion-Location header.
References
-
Tor Project onion servicesLINK: Tor Project onion services, community.torproject.org/onion-services (opens in new tab)
Official guidance on setting up and operating onion services.
-
Onion service DoS guidelinesLINK: Onion service DoS guidelines, community.torproject.org/onion-services/advanced/dos (opens in new tab)
Reference for the intro DoS and proof of work torrc options.
-
Tor Debian and Ubuntu repositoryLINK: Tor Debian and Ubuntu repository, support.torproject.org/apt (opens in new tab)
Enabling the Tor apt repository, including access over Tor.
-
Nginx documentationDOCS: Nginx documentation, nginx.org/en/docs (opens in new tab)
Directive reference for the web server configuration.