Skip to content

Hardened Onion Service on Ubuntu

Created September 12, 2026 • Last updated September 12, 2026 • 12 min read • 259 views

Joshua

HackIndex Creator

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.

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.

user@host ~ $ sudo apt update
user@host ~ $ sudo apt full-upgrade -y
user@host ~ $ sudo apt install -y chrony wget gpg lsb-release ca-certificates

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.

user@host ~ $ wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | sudo gpg --dearmor -o /usr/share/keyrings/deb.torproject.org-keyring.gpg
Explain command
--dearmor Converts the ASCII-armored signing key into the binary keyring format apt expects.
user@host ~ $ echo "deb [signed-by=/usr/share/keyrings/deb.torproject.org-keyring.gpg] https://deb.torproject.org/torproject.org $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/tor.list
user@host ~ $ sudo apt update
user@host ~ $ sudo apt install -y tor deb.torproject.org-keyring

Confirm the build ships the proof of work module. The distribution package sometimes omits it, which silently disables the defense you set below.

user@host ~ $ tor --list-modules | grep pow
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

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.

user@host ~ $ sudo systemctl restart tor
user@host ~ $ sudo cat /var/lib/tor/site/hostname
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.

user@host ~ $ sudo useradd --system --no-create-home --shell /usr/sbin/nologin --user-group www-onion
user@host ~ $ sudo mkdir -p /var/www/onion /var/log/php
user@host ~ $ echo "Service unavailable." | sudo tee /var/www/onion/error.html
user@host ~ $ sudo chown -R www-onion:www-data /var/www/onion
user@host ~ $ sudo chown www-onion:www-onion /var/log/php
user@host ~ $ sudo find /var/www/onion -type d -exec chmod 750 {} +
user@host ~ $ sudo find /var/www/onion -type f -exec chmod 640 {} +
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.

user@host ~ $ sudo apt install -y nginx

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.

user@host ~ $ sudo ln -s /etc/nginx/sites-available/onion /etc/nginx/sites-enabled/onion
user@host ~ $ sudo rm -f /etc/nginx/sites-enabled/default
user@host ~ $ sudo nginx -t
user@host ~ $ sudo systemctl reload nginx
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.

user@host ~ $ sudo apt install -y php-fpm
user@host ~ $ ls /etc/php/*/fpm/pool.d/
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.

user@host ~ $ sudo systemctl restart "php$(ls /etc/php | sort -V | tail -n1)-fpm"

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.

user@host ~ $ sudo apt install -y nftables
#!/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
    }
}
user@host ~ $ sudo nft -f /etc/nftables.conf
user@host ~ $ sudo systemctl enable --now nftables
user@host ~ $ sudo nft list ruleset

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.

user@host ~ $ sudo ss -tulpn
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.

user@host ~ $ nmap -Pn -p- $SERVER_IP
user@host ~ $ curl -m 5 -H "Host: $ONION_ADDRESS" http://$SERVER_IP/
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.

user@host ~ $ torsocks curl -I http://$ONION_ADDRESS/
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.

user@host ~ $ sudo -u www-data curl -sS -m 10 http://example.com
user@host ~ $ sudo -u www-data curl -sS -m 10 http://1.1.1.1
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.

user@host ~ $ sudo grep -rn "$CLEARNET_DOMAIN" /etc/nginx /var/www/onion

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