.

Configure Nginx to serve an .onion service

An Onion Service does not expose Nginx directly to the Internet. Tor accepts connections from the Tor network and forwards them to a local port where Nginx is listening.

Tor Browser → Tor network → Onion Service → 127.0.0.1:8081 → Nginx

The essential rule is to keep the internal port off the LAN. Nginx must bind explicitly to 127.0.0.1, not to every network interface.

Prepare the public content

Create a directory containing only files intended for visitors:

sudo mkdir -p /var/www/my-onion-site
sudo chown -R www-data:www-data /var/www/my-onion-site
sudo chmod -R 0755 /var/www/my-onion-site

The Nginx account differs between operating systems, so verify the user and group first. Never place Tor configuration, private keys, passwords, backups, or logs in this public root.

The Nginx virtual host

This example serves a static site. Replace the string of x characters with the real v3 Onion address, without http:// or a trailing slash.

server {
    listen 127.0.0.1:8081;
    server_name xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion;

    root /var/www/my-onion-site;
    index index.html;

    server_tokens off;
    autoindex off;
    client_max_body_size 1m;

    access_log /var/log/nginx/my-onion-site-access.log onion_min;
    error_log  /var/log/nginx/my-onion-site-error.log warn;

    add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
    add_header Referrer-Policy "no-referrer" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    limit_req zone=onion_site burst=40 nodelay;
    limit_req_status 429;

    location / {
        limit_except GET HEAD {
            deny all;
        }

        try_files $uri $uri/ =404;
    }

    location ~ /\. {
        deny all;
    }

    location /assets/ {
        expires 7d;
        try_files $uri =404;
    }
}

server

Opens a virtual host. One Nginx instance can host several Onion Services, each in a separate server block.

listen 127.0.0.1:8081

Binds Nginx to the IPv4 loopback interface and internal port 8081. Writing only listen 8081 could expose that port on network interfaces. Visitors will still connect to virtual port 80 of the Onion Service.

server_name

Associates this virtual host with the Onion address received in the HTTP Host header. Nginx first selects a listening address and port, then evaluates server names. If none matches, it uses the default server for that port, which is why we will add a rejection block. See How Nginx processes a request.

root

Defines the public document root. A request for /images/photo.jpg normally maps to /var/www/my-onion-site/images/photo.jpg.

index index.html

Selects the file returned when a URL names a directory. A request for / can therefore serve index.html.

server_tokens off

Hides the Nginx version number in error pages and the Server response header. The open-source edition may still emit the generic Server: nginx value, so this reduces fingerprint precision rather than eliminating it. See the server_tokens directive.

autoindex off

Prevents Nginx from generating a directory listing when no index file exists. A public gallery should be generated explicitly by a controlled application.

client_max_body_size 1m

Limits request bodies to 1 MiB. This is reasonable for a static site, while an upload application needs a deliberate limit plus quotas, isolation, file-type validation, and malware scanning.

access_log

Writes requests using the minimal onion_min format defined below. Nginx normally sees Tor as the local peer, but requested URLs, referrers, and user agents can still be sensitive. Use access_log off when no access log is needed. See the Nginx logging module.

error_log ... warn

Creates a site-specific error log and records events at warn severity or higher without enabling verbose debugging traces.

The four add_header directives

Content-Security-Policy limits resources to the same origin, disables legacy objects, forbids changes to the document base URL, and prevents framing. Adapt it if the site legitimately loads external resources.

Referrer-Policy "no-referrer" prevents the browser from sending the source page address when following a link.

X-Content-Type-Options "nosniff" tells browsers to honor the declared MIME type instead of guessing content types.

Permissions-Policy disables camera, microphone, and geolocation. Remove a restriction only when the application genuinely needs that capability.

The always parameter applies these headers to error responses as well. Without it, add_header covers only certain status codes. See the official headers module.

limit_req

Applies the onion_site rate-limiting zone. burst=40 permits a short burst of 40 requests, while nodelay processes admitted burst requests immediately.

Because Tor forwards connections from the local host, $remote_addr does not reliably distinguish visitors. This example therefore uses an explicitly global per-site limit. Setting it too low could help an attacker exhaust the shared quota. See the limit_req module.

limit_req_status 429

Returns 429 Too Many Requests when the rate limiter rejects a request. Nginx otherwise uses 503 by default.

location /

Defines the general handling of URLs on the site.

limit_except GET HEAD and deny all

Allows only resource retrieval with GET and header-only retrieval with HEAD. Do not use this restriction unchanged for forms, APIs, or uploads that need POST or other methods.

try_files $uri $uri/ =404

Checks for the requested file, then the corresponding directory, and returns 404 when neither exists. It only serves resources actually present below root. See the try_files documentation.

location ~ /\. and deny all

Rejects paths containing a hidden component such as .git, .env, or .htaccess. Such files should not be in the public root in the first place, but this provides another boundary.

location /assets/, expires, and try_files

This block handles static assets under /assets/. expires 7d lets browsers cache them for seven days, reducing traffic over Tor. Long caching is best for versioned files whose name changes with their content. try_files $uri =404 immediately rejects a missing asset. See the expires directive.

Shared directives in the http context

Some directives are not allowed in a server block. Add them to the existing Nginx http block; do not create a second http block.

http {
    server_tokens off;

    log_format onion_min
        '$time_iso8601 $request_method $status '
        '$body_bytes_sent $request_time';

    limit_req_zone $server_name
        zone=onion_site:1m
        rate=20r/s;

    gzip on;
    gzip_min_length 1024;
    gzip_vary on;
    gzip_types
        text/css
        application/javascript
        application/json
        image/svg+xml;

    include /etc/nginx/conf.d/*.conf;
}

log_format onion_min

Creates a format containing the date, HTTP method, response status, bytes sent, and processing time. It deliberately omits the client address, URL, query parameters, referrer, and user agent. log_format belongs in the http context.

limit_req_zone

Creates a shared-memory zone named onion_site. $server_name is the key, 1m reserves 1 MiB, and 20r/s sets an average of 20 requests per second. This is a global virtual-host limit, not a reliable per-visitor Tor limit.

gzip on

Enables dynamic compression. It is especially useful over Tor for HTML, CSS, JavaScript, JSON, and SVG.

gzip_min_length 1024

Avoids compressing responses smaller than 1 KiB, where compression overhead may outweigh the savings.

gzip_vary on

Adds Vary: Accept-Encoding when required so caches distinguish compressed and uncompressed responses.

gzip_types

Adds MIME types to compress. Nginx already handles text/html specially. JPEG, PNG, WebP, ZIP, and video are already compressed and generally should not be compressed again. Dynamic compression does not require public .gz files beside the originals.

include

Loads configuration fragments matching the pattern, allowing one file per service. Paths differ between Debian, Homebrew, and other installations.

Reject unexpected host names

Add a default virtual host on the same local port:

server {
    listen 127.0.0.1:8081 default_server;
    server_name "";
    access_log off;
    return 444;
}

default_server receives requests that match no named virtual host on this port. server_name "" also handles requests without a Host header. access_log off avoids logging these rejections. return 444 closes the connection without an HTTP response; 444 is an Nginx-specific code.

Configure the Onion Service in Tor

Add these lines to torrc or to an included fragment:

HiddenServiceDir /var/lib/tor/my-onion-site/
HiddenServicePort 80 127.0.0.1:8081

HiddenServiceDir

Defines the service identity directory. Tor creates the hostname file there, containing the .onion address, along with cryptographic keys proving the service identity. Every Onion Service needs a separate directory.

Those keys must remain private: anyone who obtains them can impersonate the service. Do not move, casually copy, or include this canonical directory in a general configuration backup. See the official Onion Services setup guide.

HiddenServicePort

Maps virtual port 80, seen by visitors, to local destination 127.0.0.1:8081. It does not open port 80 on a network interface.

One Tor instance can host several services with distinct HiddenServiceDir entries. A service may also have several HiddenServicePort lines; they apply to the most recently declared HiddenServiceDir.

Unix socket variant

The Tor Project recommends a Unix socket to further reduce accidental LAN exposure:

HiddenServiceDir /var/lib/tor/my-onion-site/
HiddenServicePort 80 unix:/run/tor/my-onion-site.sock

Nginx then listens on the same socket:

server {
    listen unix:/run/tor/my-onion-site.sock;
    server_name xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion;
    root /var/www/my-onion-site;
    index index.html;
}

This variant requires carefully coordinated socket ownership, groups, and permissions. A TCP connection bound to 127.0.0.1 is simpler to troubleshoot and remains off the LAN when correctly configured.

Validate before reloading

Never reload an invalid configuration:

sudo nginx -t
sudo tor --verify-config -f /etc/tor/torrc

Binary and configuration paths may differ with Homebrew or a custom install. After validation, use the operating system’s service manager. Prefer a graceful reload when supported so other sites sharing Tor or Nginx are not interrupted.

Test Nginx locally first:

curl \
  -H 'Host: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion' \
  http://127.0.0.1:8081/

Then open the Onion address in Tor Browser. Finally, verify from another LAN machine that internal port 8081 is not reachable.

Proxy a local application

For an application listening on 127.0.0.1:9000, replace the static location / handling with:

location / {
    proxy_pass http://127.0.0.1:9000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Connection "";
}

proxy_pass forwards requests to the local application, which must also bind only to loopback or a Unix socket. proxy_http_version 1.1 improves connection reuse. proxy_set_header Host forwards the requested Onion host, X-Forwarded-Proto reports the protocol received by Nginx, and Connection "" removes the previous hop’s connection-specific header.

The application must validate Host and must not trust it blindly when building links, redirects, or security decisions.

Conclusion

A robust Onion setup rests on simple boundaries: Tor owns the Onion identity, Nginx listens locally, every site has its own virtual host, the public root has no secrets, logs are minimized, and every configuration is validated before a reload.

One Tor instance and one Nginx instance can host multiple Onion Services. Separate processes, containers, or virtual machines become valuable when sites run untrusted code or have different trust requirements.

Sources