DevOps on AlipourIm journeyshttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/categories/devops/Recent content in DevOps on AlipourIm journeysHugo -- 0.146.0enFri, 19 Sep 2025 00:00:00 +0000Running my blog on Tor (.onion) and the Clearnet with Hugo + PaperModhttp://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/Fri, 19 Sep 2025 00:00:00 +0000http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/tor_clearnet_blog/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.

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:

H H i i d d d d e e n n S S e e r r v v i i c c e e D P i o r r t v 8 a 0 r / 1 l 2 i 7 b . 0 t . o 0 r . 1 h : i 3 d 3 d 0 e 1 n _ s i t e /

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 baseURLs:

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

/ / e e t t c c / / l l e e t t s s e e n n c c r r y y p p t t / / l l i i v v e e / / b b l l o o g g . . a a l l i i p p o o u u r r i i m m j j o o u u r r n n e e y y s s . . i i r r / / f p u r l i l v c k h e a y i . n p . e p m e m

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