- HTML 67%
- Shell 31.6%
- Go Template 1.4%
| blog | ||
| nginx | ||
| scripts | ||
| www | ||
| .gitignore | ||
| README.md | ||
| site.conf | ||
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,nginxsudofor the user running the deploycertbotandpython3-certbot-nginx, on the clearnet box onlytor, 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.confwithHiddenServiceDirandHiddenServicePort - adds
%include /etc/tor/torrc.d/*.confto/etc/tor/torrcif 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.txt—12B0 89F3 317A DCEB 4A23 0F7D 1E36 26B1 04EC D1B7/ssh.txt—SHA256:P4JXWHt0TbaPqZb4YwRsy7ouYljh54MHA1RV6zt81sc
Reusing this for another domain
- Edit
site.conf - Rewrite the copy in
www/index.html— title, prompt, mailto, links - Generate a new onion key, set
ONION_HOSTNAME - 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