# Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperMod

How I hosted a Hugo + PaperMod site both as a Tor onion service and a normal HTTPS site behind Cloudflare, with clean configs, dual builds, and a one-command deploy.
September 19, 2025 · 6 min · Iman Alipour Comments

> TL;DR — The site is built once (Hugo project), published twice:

> - Onion: `http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/` via Tor, no TLS/HSTS, bound to `127.0.0.1:3301`.
> - Clearnet: `https://blog.alipourimjourneys.ir` behind Cloudflare, Let’s Encrypt cert, `Onion-Location` header pointing to the onion mirror.

## 1) Tor hidden service (onion) basics#

I used Tor’s v3 onion services and mapped onion port 80 → my local web server on `127.0.0.1:3301`.

Install & configure Tor (Debian/Ubuntu):

    sudo apt update && sudo apt install -y tor
    sudoedit /etc/tor/torrc

Add:

Make sure the directory is owned by Tor’s user and private:

    sudo mkdir -p /var/lib/tor/hidden_site
    sudo chown -R debian-tor:debian-tor /var/lib/tor/hidden_site
    sudo chmod 700 /var/lib/tor/hidden_site

Important: use the right systemd unit:

    # validate as the Tor user
    sudo -u debian-tor tor -f /etc/tor/torrc --verify-config

    # (re)start the real service
    sudo systemctl enable --now tor@default
    sudo systemctl restart tor@default

Get the onion address:

    sudo cat /var/lib/tor/hidden_site/hostname

Quick check (do remote DNS via SOCKS):

    curl -I --socks5-hostname 127.0.0.1:9050 "http://$(sudo cat /var/lib/tor/hidden_site/hostname)"

## 2) Nginx for the onion (localhost-only)#

I keep the onion site strictly on localhost: no HTTPS, no redirects, no HSTS. Tor already provides e2e encryption and authenticity.

`/etc/nginx/sites-available/onion-blog`:

    server {
        listen 127.0.0.1:3301 default_server;
        server_name <your-56-char>.onion 127.0.0.1 localhost;

        # keep onion simple; no HSTS/redirects here
        add_header Referrer-Policy no-referrer always;
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options SAMEORIGIN always;
        # (optional) strict CSP so nothing leaks to clearnet
        # add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;

        root /srv/hugo/mysite/public-onion;
        index index.html;

        location / { try_files $uri $uri/ /index.html; }
        location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
            access_log off;
            add_header Cache-Control "public, max-age=31536000, immutable";
            try_files $uri =404;
        }
    }

Enable/reload:

    sudo ln -sf /etc/nginx/sites-available/onion-blog /etc/nginx/sites-enabled/onion-blog
    sudo nginx -t && sudo systemctl reload nginx

> Gotcha I hit: I had two server blocks on `127.0.0.1:3301`, which caused 404s via onion. Make sure only the intended vhost listens there (or mark it `default_server`). Also, if you ever use a regex in `server_name`, the syntax is `server_name ~* \.onion$` (note the space after `~*`).

## 3) Hugo + PaperMod setup (and version bumps)#

PaperMod now requires Hugo Extended ≥ 0.146.0. I installed the extended binary from the official tarball to avoid Snap’s sandbox limitations (Snap can’t read `/srv` paths by default).

    # install Hugo extended (example)
    VER=0.146.0
    cd /tmp
    wget https://github.com/gohugoio/hugo/releases/download/v${VER}/hugo_extended_${VER}_Linux-amd64.tar.gz
    tar -xzf hugo_extended_${VER}_Linux-amd64.tar.gz
    sudo mv hugo /usr/local/bin/hugo
    hugo version  # should say "extended" and >= 0.146.0

Create the site and theme:

    sudo mkdir -p /srv/hugo && sudo chown -R "$USER":"$USER" /srv/hugo
    cd /srv/hugo
    hugo new site mysite
    cd mysite
    git init
    git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod

Config updates: in newer Hugo, `paginate` is deprecated → use:

    # config/_default/hugo.toml
    title = "My Blog"
    theme = "PaperMod"
    enableRobotsTXT = true

    [pagination]
      pagerSize = 10

    [params]
      defaultTheme = "auto"
      showReadingTime = true
      showPostNavLinks = true
      showBreadCrumbs = true
      showCodeCopyButtons = true

I added per-environment overrides so I can build two outputs with different `baseURL`s:

    # config/clearnet/hugo.toml
    baseURL = "https://blog.alipourimjourneys.ir/"

    # config/onion/hugo.toml
    baseURL = "http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/"

## 4) Dual builds (clearnet + onion) and one-command deploy#

I publish the same content twice—once for each base URL and docroot:

    cd /srv/hugo/mysite
    # clearnet build (served over HTTPS)
    hugo --minify --environment clearnet -d public-clearnet
    # onion build (served via Tor)
    hugo --minify --environment onion    -d public-onion
    sudo systemctl reload nginx

Helper script I use:

    sudo tee /usr/local/bin/build-both >/dev/null <<'EOF'
    #!/usr/bin/env bash
    set -euo pipefail
    cd /srv/hugo/mysite
    hugo --minify --environment clearnet -d public-clearnet
    hugo --minify --environment onion    -d public-onion
    sudo systemctl reload nginx
    echo "Deployed both at $(date)"
    EOF
    sudo chmod +x /usr/local/bin/build-both

While editing, I sometimes auto-rebuild on file save:

    sudo apt install -y entr
    cd /srv/hugo/mysite
    find content layouts assets static config -type f | entr -r build-both

## 5) Clearnet behind Cloudflare + Let’s Encrypt (manual DNS-01)#

Cloudflare is set to Full (strict). I issued a public cert for `blog.alipourimjourneys.ir` using manual DNS-01 (no API token):

    sudo snap install --classic certbot
    sudo certbot certonly --manual --preferred-challenges dns   -d blog.alipourimjourneys.ir   --agree-tos -m you@example.com --no-eff-email

Certbot tells you to add a TXT record `_acme-challenge.blog.alipourimjourneys.ir`. Add it in Cloudflare DNS, verify with `dig`, then continue. Cert ends up at:

Clearnet Nginx vhosts:

    # HTTP → HTTPS redirect (optional; CF usually talks HTTPS to origin anyway)
    server {
      listen 80; listen [::]:80;
      server_name blog.alipourimjourneys.ir;
      return 301 https://blog.alipourimjourneys.ir$request_uri;
    }

    # HTTPS origin (behind Cloudflare)
    server {
      listen 443 ssl http2; listen [::]:443 ssl http2;
      server_name blog.alipourimjourneys.ir;

      ssl_certificate     /etc/letsencrypt/live/blog.alipourimjourneys.ir/fullchain.pem;
      ssl_certificate_key /etc/letsencrypt/live/blog.alipourimjourneys.ir/privkey.pem;

      # HSTS on clearnet only
      add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

      # Help Tor Browser discover the onion mirror (safe on clearnet)
      add_header Onion-Location "http://<your-56-char>.onion$request_uri" always;

      root /srv/hugo/mysite/public-clearnet;
      index index.html;

      location / { try_files $uri $uri/ /index.html; }
    }

> Note: don’t add HSTS or HTTPS redirects to the onion vhost. Keep onion pure HTTP on localhost.

## 6) Troubleshooting I ran into (and fixes)#

- Tor unit confusion: `tor.service` is a tiny master; the real daemon is `tor@default`. Use that unit and verify config as `debian-tor`.
- Permissions: `HiddenServiceDir` must be owned by `debian-tor` and mode `700`.
- Mapping mismatch: If `HiddenServicePort 80 127.0.0.1:3301` is set, visit `http://<onion>/` (no `:3301`). If you set `HiddenServicePort 3301 127.0.0.1:3301`, you must use `http://<onion>:3301/`.
- Curl & .onion: modern curl refuses `.onion` unless you use remote DNS via SOCKS:

      curl -I --socks5-hostname 127.0.0.1:9050 "http://<onion>/"

- Two Nginx vhosts on the same port: I had a duplicate server on `127.0.0.1:3301` pointing somewhere else, which caused onion 404s. Keep only one (or mark one `default_server`).
- Regex in `server_name`: if you use it, write `server_name ~* \.onion$` (space after `~*`). I fixed an `invalid variable name` error caused by a missing space.
- PaperMod with old Hugo: upgraded to extended ≥ 0.146.0. Also updated `paginate` → `[pagination].pagerSize`.
- Snap confinement: Snap’s `hugo` couldn’t read `/srv` (`.../void: permission denied`). Switched to the tarball build in `/usr/local/bin`.

## 7) “Not secure” in Tor Browser?#

That message can appear because onion uses HTTP. It’s OK: Tor provides e2e encryption + onion auth. If you enable HTTPS-Only Mode, add an exception for the site. Also ensure the onion build doesn’t reference clearnet resources (scan the built HTML for `http(s)://` links that aren’t your onion).

## 8) Day-to-day workflow#

- Create content:

      hugo new posts/my-first-post.md  # then set draft: false

- Publish both:

      build-both

- (Optional) Auto on save:

      find content layouts assets static config -type f | entr -r build-both

- (Optional) Git push-to-deploy with a bare repo + post-receive hook that runs `build-both`.

## Final notes#

- Clearnet gets HTTPS + HSTS and an `Onion-Location` header.
- Onion gets no HSTS/redirects, and all assets are self-hosted to avoid mixed content.
- Serving both worlds from one Hugo repo is easy: two builds, two vhosts, one workflow.

Open on GitHub ↗
Comments powered by Isso
RSS