No description
  • HTML 67%
  • Shell 31.6%
  • Go Template 1.4%
Find a file
2026-08-26 18:56:23 +02:00
blog point git/gist links at tarsow.dev and harden .gitignore 2026-08-26 18:48:20 +02:00
nginx polished starter version 2026-08-07 17:14:45 +02:00
scripts polished starter version 2026-08-07 17:14:45 +02:00
www publish tarsow-only pgp key 2026-08-26 18:56:23 +02:00
.gitignore point git/gist links at tarsow.dev and harden .gitignore 2026-08-26 18:48:20 +02:00
README.md polished starter version 2026-08-07 17:14:45 +02:00
site.conf polished starter version 2026-08-07 17:14:45 +02:00

tarsow.dev

My personal site. Static HTML served directly by nginx, on two machines: one on the clearnet, one as a Tor onion service. No framework, no JavaScript, no build step beyond a shell script that turns markdown into blog pages.

Both machines serve identical content. Neither needs access to the other.

Requirements

On each box:

  • git, bash, python3, rsync, nginx
  • sudo for the user running the deploy
  • certbot and python3-certbot-nginx, on the clearnet box only
  • tor, on the onion box only

The deploy runs as an ordinary user and calls sudo for the privileged steps. It does not want to be run as root — doing that leaves the generated blog files in the repo owned by root, which breaks the next git pull.

rsync is optional but wanted. Without it the deploy falls back to cp and stops pruning files I have deleted from www/, which means anything I unpublish stays on the server.

Configuration

Everything that changes between deployments lives in site.conf at the repo root. Every script sources it.

Variable What it controls
SITE_NAME nginx server_name, the vhost filename, log filenames, the <title> suffix and the root@…: prompt on generated blog pages
SITE_ALIASES extra server_name entries, space separated, may be empty
WEB_ROOT where the site lands on each box
CERTBOT_ENABLED yes runs scripts/certbot.sh at the end of deploy.sh. no on the onion box
CERTBOT_EMAIL registration address for Let's Encrypt expiry notices
CERTBOT_EXTRA_ARGS extra flags, --staging while testing
ONION_HOSTNAME the 56-character onion address, checked against what Tor serves and advertised by clearnet responses
TOR_HS_DIR Tor's HiddenServiceDir
ONION_LISTEN the loopback address:port the onion vhost binds

SITE_NAME and WEB_ROOT are required. ONION_HOSTNAME is also required by scripts/deploy.sh; it must be a v3 onion hostname so the clearnet vhost cannot emit an empty or malformed Onion-Location header. The other onion values are only read by scripts/deploy-tor.sh.

One thing the config does not reach: www/index.html. That page is hand-written on purpose, so it still contains the domain in its title, prompt, and mailto. Changing domains means editing that page's copy, which I would be doing anyway.

Layout

site.conf                  all configuration
blog/                      posts, as markdown
www/                       exactly what gets served
www/index.html             hand-written
www/blog/                  generated, do not edit
nginx/clearnet.conf.tmpl   clearnet vhost template
nginx/onion.conf.tmpl      onion vhost template
scripts/lib.sh             shared by every script
scripts/deploy.sh          clearnet box
scripts/deploy-tor.sh      onion box
scripts/generate-blog-*    markdown to html

Anything under www/ gets published, dotfiles included. Private keys, backups and notes stay out of it.

Writing a post

Drop a markdown file in blog/. Start it with explicit creation and update dates, followed by the title:

---
created: 2026-08-04
updated: 2026-08-04
---

# Post Title

Both dates use YYYY-MM-DD. Keep created unchanged and update updated only when the article's content changes. The blog index displays and sorts by the creation date. Each post page displays both values. Filesystem mtimes are not used because a checkout or copy can change them without changing the article.

scripts/generate-blog-index.sh

That regenerates every post page and rebuilds www/blog/index.html, deleting generated pages whose markdown source is gone. The deploy runs it, so I only call it directly to preview locally.

The renderer validates the date metadata and handles headings, paragraphs, lists, inline code, fenced code blocks and links. If I need more I extend scripts/generate-blog-page.sh rather than adding a dependency.

First-time setup

Both boxes

git clone <repo> && cd tarsow-dev-2
$EDITOR site.conf

Onion address, before deploying anything

Generating the key up front means the address exists before the first deploy, so www/index.html can be right the first time. Skipping this costs an extra round trip; see the fallback below.

mkp224o grinds a vanity prefix. It cannot produce a short name — every v3 address is exactly 56 characters, because the address is the public key encoded. Only the first characters are mine to choose.

git clone https://github.com/cathugger/mkp224o && cd mkp224o
./autogen.sh && ./configure && make
./mkp224o tarsow -d ~/onions

Six characters took about 40 minutes on 16 threads. Each additional character is roughly 32x the work. It produces many matches; I keep one. Serving the same site under several addresses only fragments trust and gives an impersonator cover.

Then set ONION_HOSTNAME in site.conf to the address, and paste it into the darknet line of www/index.html. Commit and push.

Clearnet box

git pull
scripts/deploy.sh

Regenerates the blog, syncs www/ to WEB_ROOT, fixes ownership and modes, renders clearnet.conf.tmpl, installs and links the vhost, runs nginx -t, reloads. The rendered vhost advertises the corresponding onion URL in an Onion-Location response header.

Onion box

Install the key first, or Tor will generate a different one and the deploy will refuse to continue:

sudo apt install tor
sudo cp -r ~/onions/tarsow… /var/lib/tor/tarsow
sudo chown -R debian-tor:debian-tor /var/lib/tor/tarsow
sudo chmod 700 /var/lib/tor/tarsow

git pull
scripts/deploy-tor.sh

That script does the rest:

  • publishes the content, same as the clearnet box
  • writes /etc/tor/torrc.d/tarsow.dev-onion.conf with HiddenServiceDir and HiddenServicePort
  • adds %include /etc/tor/torrc.d/*.conf to /etc/tor/torrc if missing, since Debian does not ship that line and a drop-in without it is silently ignored
  • fixes ownership and mode on TOR_HS_DIR, the usual thing that breaks after restoring a key backup
  • restarts Tor and waits up to 30s for the hostname file
  • aborts if Tor's address does not match ONION_HOSTNAME
  • renders onion.conf.tmpl, installs the vhost, nginx -t, reloads

Rerunning is safe. Tor keeps the existing key, so the address does not change.

Fallback without a pre-generated key

Leave ONION_HOSTNAME empty. deploy-tor.sh lets Tor mint an address, uses it for the vhost, and prints it. Then paste it into site.conf and www/index.html, push, and redeploy both boxes.

Day to day

git pull && scripts/deploy.sh        # clearnet box
git pull && scripts/deploy-tor.sh    # onion box

Order between the boxes does not matter. They are independent.

Why the address check matters

If TOR_HS_DIR is ever empty — rebuilt box, wiped directory, forgotten key restore — Tor generates a new address and everything otherwise appears to work. nginx serves normally, because the onion vhost is the only server on its socket and answers regardless of the Host header. The only symptom is that the address published on my site reaches nothing.

Comparing Tor's hostname against ONION_HOSTNAME turns that silent outage into an error at deploy time.

Why the onion vhost binds to loopback

ONION_LISTEN is 127.0.0.1:8080, never a public interface. Tor is the only thing that connects to it.

If it were bound publicly, the box would serve byte-identical content on both the clearnet IP and the onion address, and anyone scanning could line the two up. The loopback bind means there is nothing to scan.

The vhost also disables access_log, since every request arrives from 127.0.0.1 and the log would record request paths while telling me nothing useful. server_tokens off keeps the nginx version out of responses.

No TLS on the onion vhost, and none needed. The onion protocol already authenticates and encrypts end to end against that key.

Keys and secrets

Where Why
live onion key TOR_HS_DIR on the onion box, 700 debian-tor the only place Tor reads it
backup GPG-encrypted, off the box rebuilds and migration
this repo nowhere the remotes are public

hs_ed25519_secret_key is the address. Lose it and the address is gone permanently. Commit it and anyone can stand up a service at my address that visitors' Tor clients will validate as authentic — and git rm does not help once it is in history. .gitignore covers the key filenames and *.tar.gz.gpg, but the real defence is not putting them here.

Backup:

sudo tar czf - -C /var/lib/tor tarsow \
  | gpg --encrypt --recipient 1E3626B104ECD1B7 --output ~/onion-backup.tar.gz.gpg

Restore, to a rebuilt or replacement box:

gpg --decrypt onion-backup.tar.gz.gpg | sudo tar xzf - -C /var/lib/tor/
sudo chown -R debian-tor:debian-tor /var/lib/tor/tarsow
sudo chmod 700 /var/lib/tor/tarsow
scripts/deploy-tor.sh

Published keys

Both are linked from the front page with their fingerprints, so they can be checked against a channel I do not control:

  • /pgp.txt12B0 89F3 317A DCEB 4A23 0F7D 1E36 26B1 04EC D1B7
  • /ssh.txtSHA256:P4JXWHt0TbaPqZb4YwRsy7ouYljh54MHA1RV6zt81sc

Reusing this for another domain

  1. Edit site.conf
  2. Rewrite the copy in www/index.html — title, prompt, mailto, links
  3. Generate a new onion key, set ONION_HOSTNAME
  4. Deploy

Nothing else carries the old domain.

TLS

scripts/certbot.sh runs as the last step of deploy.sh, on the clearnet box only. It is also runnable on its own.

Before the first deploy, DNS for SITE_NAME and every name in SITE_ALIASES has to point at the box, and ports 80 and 443 have to be reachable. Let's Encrypt validates over port 80.

dig +short tarsow.dev
dig +short www.tarsow.dev

Why it runs last

certbot --nginx edits the vhost in place, and deploy.sh rewrites that same file from the template every run. So the order matters: the template resets the vhost to plain HTTP, then certbot layers TLS and the HTTP-to-HTTPS redirect back on top. Running certbot before the vhost was installed would just get overwritten.

--reinstall is what makes this safe to repeat. With a valid certificate already on disk certbot skips issuance and only re-applies the nginx changes, so a deploy does not count against the rate limit.

There is a short window mid-deploy where the site is HTTP-only, between the vhost being installed and certbot finishing.

Onion-Location

The clearnet nginx template sends the onion address with the original request URI:

add_header Onion-Location "http://<56-character-address>.onion$request_uri" always;

Tor Browser only accepts Onion-Location from a page served over HTTPS. The $request_uri variable preserves the requested path and query string, so a clearnet blog URL advertises the matching onion URL instead of only the onion home page. The always parameter also keeps the header on nginx error responses.

After deploying the clearnet box, check the installed behavior with:

curl --silent --show-error --head 'https://tarsow.dev/blog/?check=onion' \
  | grep -i '^onion-location:'

When it fails

A certbot failure prints a warning and leaves the site on plain HTTP. It does not fail the deploy — a DNS problem or a rate limit should not take the site down. Rerun after fixing, or set CERTBOT_ENABLED="no" to stop trying.

The live rate limit is 5 failed validations per hostname per hour, which is easy to hit while debugging. Put --staging in CERTBOT_EXTRA_ARGS while working it out, then clear it and rerun to get a real certificate.

Renewal

certbot's systemd timer handles renewal. certbot.sh installs /etc/letsencrypt/renewal-hooks/deploy/reload-nginx on first run, because a renewed certificate sits unused until nginx reloads.

sudo certbot renew --dry-run
systemctl list-timers certbot.timer