# Hardened Onion Service on Ubuntu

> Set up a hardened onion service on Ubuntu or Debian with Tor, Nginx, PHP-FPM, egress lockdown and leak testing before you publish.

- **URL:** https://hackindex.io/blog/hardened-onion-service-ubuntu
- **Authors:** [Joshua](https://hackindex.io/authors/joshua.md)
- **Published:** 2026-09-12
- **Updated:** 2026-09-12

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.

```bash
sudo apt update
sudo apt full-upgrade -y
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.

```bash
wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | sudo gpg --dearmor -o /usr/share/keyrings/deb.torproject.org-keyring.gpg
```

```bash
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
sudo apt update
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.

```bash
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](https://hackindex.io/blog/vanity-onion-address-mkp224o) and [running multiple onion services on one host](https://hackindex.io/blog/multiple-onion-services).

```
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
```

> [!CAUTION]
> 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.

```bash
sudo systemctl restart tor
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.

```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --user-group www-onion
sudo mkdir -p /var/www/onion /var/log/php
echo "Service unavailable." | sudo tee /var/www/onion/error.html
sudo chown -R www-onion:www-data /var/www/onion
sudo chown www-onion:www-onion /var/log/php
sudo find /var/www/onion -type d -exec chmod 750 {} +
sudo find /var/www/onion -type f -exec chmod 640 {} +
# Service unavailable.
```

## 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.

```bash
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.

```nginx
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.

```bash
sudo ln -s /etc/nginx/sites-available/onion /etc/nginx/sites-enabled/onion
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
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
```

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.

```nginx
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.

```apache
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.

```bash
sudo apt install -y php-fpm
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`.

```ini
[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.

```bash
sudo systemctl restart "php$(ls /etc/php | sort -V | tail -n1)-fpm"
```

> [!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](https://hackindex.io/blog/ssh-over-onion).

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`.

```bash
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
    }
}
```

```bash
sudo nft -f /etc/nftables.conf
sudo systemctl enable --now nftables
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.

```bash
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.

```bash
nmap -Pn -p- $SERVER_IP
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
```

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.

```bash
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
```

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.

```bash
sudo -u www-data curl -sS -m 10 http://example.com
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
```

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.

```bash
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](https://hackindex.io/blog/vanity-onion-address-mkp224o), run [several onions from one daemon](https://hackindex.io/blog/multiple-onion-services), and advertise the onion to clearnet visitors with an [Onion-Location header](https://hackindex.io/blog/onion-location-header).

## References

- [Tor Project onion services](https://community.torproject.org/onion-services/) - Official guidance on setting up and operating onion services.
- [Onion service DoS guidelines](https://community.torproject.org/onion-services/advanced/dos/) - Reference for the intro DoS and proof of work torrc options.
- [Tor Debian and Ubuntu repository](https://support.torproject.org/apt/) - Enabling the Tor apt repository, including access over Tor.
- [Nginx documentation](https://nginx.org/en/docs/) - Directive reference for the web server configuration.
