How to make your website available over Tor hidden service on NixOS

A guide on Tor hidden service on NixOS

  1. Launch Tor
  2. caddyTor.nix
    1. File ownership and permissions
  3. caddyFile
    1. Alternate Caddyfile
  4. Launch Caddy
  5. Snowflake proxy (optional)

9 Nov 2020: Updated to Caddy 2.1 syntax. Refer to this article for upgrade guide.

In this segment, I show you how I set up Tor hidden (.onion) service that reverse proxy to This website can be accessed through the following .onion address.

This post is Part 4 of a series of articles that show you how I set up Caddy, Tor hidden service and I2P Eepsite on NixOS:

The main reason for me to have a Tor hidden service is so that visitor can visit my website ( anonymously. Visitor indeed can browse this website somewhat anonymously via VPN, but it’s not hidden from the VPN provider. Even with Tor, the traffic still needs to get out from the Tor network to the Internet via exit relays, and exit relays can do whatever they want to the traffic. Tor hidden service ensures the traffic is end-to-end encrypted and stays inside the Tor network–without involving any exit relay.

Note that this only applies to the traffic between visitor and the (Caddy) web server, as shown in the following diagram; a request still needs to get passed to the upstream, but Netlify only sees the request comes from my web server as if it’s just a regular visitor and shouldn’t know that its origin from the Tor network.

Architecture behind

Launch Tor §

The first step is to bring up a Tor hidden service to get an onion address. Add the following options to configuration.nix:

## Tor onion services.tor = { enable = true; enableGeoIP = false; relay.onionServices = { myOnion = { version = 3; map = [{ port = 80; target = { addr = "[::1]"; port = 8080; }; }]; }; }; settings = { ClientUseIPv4 = false; ClientUseIPv6 = true; ClientPreferIPv6ORPort = true; }; };
  1. enableGeoIP is disabled as I don’t need by-country statistics.
  2. I name the service as “myOnion”, so the keys will be stored in “/var/lib/tor/onion/myOnion“ folder.
  3. Set the version to 3, which is a more secure version. The most noticable difference is that the generated onion address will be 56-character long, which is much longer than v2’s 16-character. Tor already defaults to v3 since 0.3.5, but I set it just to make sure.
  4. port sets the port number that the hidden service binds to. Recommend to set it to port 80.
  • If you set it to “1234”, visitor needs to specify the port number to browse your site, e.g. http://foobar.onion:1234
  • There is no need to grant CAP_NET_BIND_SERVICE capability nor open port 80. Tor has NAT traversal capability and can function without opening any inbound port.
  • Add port 443 if your onion service is also available in HTTPS; I wrote a guide on purchasing a .onion SSL certificate and the subsequent configuration.
  1. toHost is location of your web server. In my case, it is the IPv6 loopback [::1]. If your server supports IPv4 (mine doesn’t), you can set it to “” or “localhost”. If it’s an IPv6 address, you need to wrap the address with square brackets [].
  • You can even set your domain here and skip the rest of the sections. However, this can double the latency, especially if the website is behind a CDN. Tor recommends to have a separate web server that is dedicated for Tor hidden service only. The next section shows how to set up the web server.
  1. toPort is the port number that your web server listens to.
  2. extraConfig is optional. The options I use here are only applicable if the server is IPv6 only.

Run # nixos-rebuild switch and three important files will be generated in the “/var/lib/tor/onion/myOnion“ folder.

  1. hostname your unique onion address, note this down. The address is derived from the private key.
  2. hs_ed25519_public_key ED25519 elliptic-curve public key. Backup this key.
  3. hs_ed25519_private_key Absolutely backup this key and protect it with your own life. Losing this file means losing the onion address.

Backup the keys. If you migrate to another server, you just need to move the keys, Tor will generate the same hostname from the private key.

caddyTor.nix §

I set up another Caddy-powered reverse proxy which is separate from the's. It’s similar to caddyProxy.nix, except I replace “caddyProxy” with “caddyTor”. This Nix file exposes services.caddyTor so that I can enable the Tor-related Caddy service from “configuration.nix”.

{ config, lib, pkgs, ... }: with lib; let cfg =; in { = { enable = mkEnableOption "Caddy web server"; config = mkOption { default = "/etc/caddy/caddyProxy.conf"; type = types.str; description = "Path to Caddyfile"; }; adapter = mkOption { default = "caddyfile"; example = "nginx"; type = types.str; description = '' Name of the config adapter to use. See for the full list. ''; }; dataDir = mkOption { default = "/var/lib/caddyProxy"; type = types.path; description = '' The data directory, for storing certificates. Before 17.09, this would create a .caddy directory. With 17.09 the contents of the .caddy directory are in the specified data directory instead. ''; }; package = mkOption { default = pkgs.caddy; defaultText = "pkgs.caddy"; type = types.package; description = "Caddy package to use."; }; }; config = mkIf cfg.enable { = { description = "Caddy web server"; after = [ "" ]; wants = [ "" ]; # systemd-networkd-wait-online.service wantedBy = [ "" ]; startLimitIntervalSec = 14400; startLimitBurst = 10; serviceConfig = { ExecStart = "${cfg.package}/bin/caddy run --config ${cfg.config} --adapter ${cfg.adapter}"; ExecReload = "${cfg.package}/bin/caddy reload --config ${cfg.config} --adapter ${cfg.adapter}"; Type = "simple"; User = "caddyProxy"; Group = "caddyProxy"; Restart = "on-abnormal"; # < 20.09 # # StartLimitIntervalSec = 14400; # StartLimitBurst = 10; NoNewPrivileges = true; LimitNPROC = 512; LimitNOFILE = 1048576; PrivateTmp = true; PrivateDevices = true; ProtectHome = true; ProtectSystem = "full"; ReadWriteDirectories = cfg.dataDir; KillMode = "mixed"; KillSignal = "SIGQUIT"; TimeoutStopSec = "5s"; }; }; users.users.caddyProxy = { home = cfg.dataDir; createHome = true; }; users.groups.caddyProxy = { members = [ "caddyProxy" ]; }; }; }

File ownership and permissions §

After you save the file to /etc/caddy/CaddyTor.nix, remember to restrict it to root.

# chown root:root /etc/caddy/caddyTor.nix
# chown 600 /etc/caddy/caddyTor.nix

caddyFile §

Create a new caddyFile in /etc/caddy/caddyTor.conf and starts with the following config:

import common.conf

# Tor onion
http://xw226dvxac7jzcpsf4xb64r4epr6o5hgn46dxlqk7gnjptakik6xnzqd.onion:8080 {
  bind ::1

  header {
    import setHeaders

  import pathProxy

Update the onion address to the value shown in “/var/lib/tor/onion/myOnion/hostname“. HTTPS is disabled by specifying http:// prefix, HTTPS is not necessary as Tor hidden service already encrypts the traffic. Let’s Encrypt doesn’t support validating a .onion address. The only way is to purchase the cert from Digicert. Since HTTPS is not enabled, strict-transport-security (HSTS) no longer applies and the header needs to be removed to prevent the browser from attempting to connect to https://. It binds to IPv6 loopback so it only listens to localhost, specify bind ::1 if you need IPv4.

The rest are similar to “caddyProxy.conf“. Content of “common.conf” is available at this section.

import common.conf # Tor onion http://xw226dvxac7jzcpsf4xb64r4epr6o5hgn46dxlqk7gnjptakik6xnzqd.onion:8080 { bind ::1 header { import setHeaders -strict-transport-security defer } import pathProxy }

Alternate Caddyfile §

There is another approach which has a much simpler Caddyfile, but it doubles the latency. I could simply reverse proxy to but that itself is also a reverse proxy, so it would add one more roundtrip.

This is also suitable if you have a website that you don’t have root access (e.g. GitHub Pages).

# Do not use this approach unless you are absolutely sure
http://xw226dvxac7jzcpsf4xb64r4epr6o5hgn46dxlqk7gnjptakik6xnzqd.onion:8080 {
  bind ::1

  header {

  reverse_proxy {
    header_up Host

Launch Caddy §

Start the Caddy service.

require = [ /etc/caddy/caddyProxy.nix /etc/caddy/caddyTor.nix ]; services.caddyTor = { enable = true; config = "/etc/caddy/caddyTor.conf"; };

Tor hidden service needs some time to announce to the Tor network, wait for a few hours before trying your newfangled onion address.

Snowflake proxy (optional) §

Snowflake is an alternative method to connect to the Tor network, useful when connections to entry nodes and bridge have been restricted. Volunteers can run Snowflake proxy to enable people who are censored to use it to access the Tor network. Snowflake proxy is available in NixOS 22.05+.

services.snowflake-proxy = { enable = true; capacity = 100; };

capacity sets the maximum concurrent clients and there is no limit by default. I set 100 as a precaution. In my experience, on average there are 10-20 clients every hour, with a total 2 GB daily traffic for each direction (2 GB ingress & 2 GB egress). Assuming your VPS provider set a quota based on whichever direction is higher (like Vultr), expect less than 100 GB of monthly traffic.