Nginx And This Site
I started building tarsow.dev as a small static site served directly by nginx. I wanted a place for my code, writing, keys, and network addresses that did not need a framework, a database, or an application server.
The main requirement was that I should be able to understand the whole site by reading the repository. I did not want a dependency tree between me and a page of text. If the site stopped working, there should be a short path from the request to the file nginx was supposed to return.
The complete source is in the tarsow-dev-2 repository. This post explains how that first small version became the current two-machine deployment, and which failures changed the design.
What I Wanted
The visible site is intentionally small. The front page is hand-written HTML, the blog is written as Markdown, and nginx serves the generated files directly. There is no client-side JavaScript and no server-side program handling requests.
That was not only an aesthetic choice. It gives every component one job:
blog/contains the writing sourcescripts/generate-blog-page.shturns one post into HTMLscripts/generate-blog-index.shrebuilds the posts and their indexwww/contains exactly what is public- nginx maps requests to files under the deployed web root
- the deployment scripts move the known source into place and validate the server
The nginx documentation describes the same basic static-serving model: a request URI is mapped through the configured root to a file on disk. For this site, there is no upstream application behind that mapping.
The First Version
The first version put the blog Markdown directly under www/blog/. A shell script generated the index, but nginx served the post sources themselves. The domain, web root, and nginx paths were hard-coded in the deployment script and vhost.
The first deployment was roughly:
repository www/
|
v
copy to /var/www/tarsow.dev
|
v
install nginx vhost
|
v
nginx -t and reload
It was a useful starting point because it proved the basic shape. It also made the boundaries unclear. Authored Markdown lived inside the directory described as public, presentation depended on how a browser handled that Markdown, and configuration values were repeated across files.
I moved the authored posts into blog/ and added a small Python renderer inside the shell generator. The browser now receives ordinary HTML, while I still write the source as Markdown. The renderer handles the limited set of features the site uses: headings, paragraphs, lists, links, inline code, and fenced code blocks.
The current path is:
blog/*.md
|
v
scripts/generate-blog-page.sh
|
v
www/blog/*.html
|
v
nginx
The narrow renderer is a deliberate tradeoff. It has no package dependency and its behavior is visible in one file, but every new Markdown feature has to be implemented and escaped correctly. Unsupported syntax is not silently treated as trusted HTML.
Making The Deployment Repeatable
The current shared deployment code regenerates the blog before publishing. It uses rsync --delete when available, so a file removed from www/ is also removed from the live web root. The fallback uses cp, which keeps the site available but cannot prune stale files.
After copying, the script fixes ownership and modes, renders the nginx template, installs the vhost, runs nginx -t, and reloads nginx. Testing before the reload is the important boundary: a bad generated configuration must stop the deploy before it replaces a working configuration in memory. Nginx also documents that a configuration reload validates the new configuration and keeps the old worker processes when it cannot apply it.
I run the deployment as an ordinary user and use sudo only for the operations that need it. Running the whole deploy as root created a less obvious problem: the blog generator wrote root-owned files back into the Git checkout, which broke the next ordinary git pull.
Another failure came from command discovery. On Debian, nginx and Tor live in /usr/sbin, which is not always in an unprivileged user's PATH. The programs were installed and the privileged commands already used sudo, but the early availability check still failed. The shared library now appends the standard sbin directories before checking commands.
These are small problems, but they are the kind that decide whether a deployment is actually repeatable. A script that works only from one shell, or only when run as root, is not the deployment I wanted.
One Site On Two Machines
The site is served by two independent machines. One serves the clearnet domain and the other serves the Tor onion address. They publish the same www/ tree, but neither machine needs access to the other.
The shorter Tor Node post explains the difference between operating a relay and running the onion service that publishes this site.
I first added a second nginx vhost and a Tor deployment script. The later change was to move every shared value into site.conf and every shared operation into scripts/lib.sh. The result is one content pipeline with two deployment edges:
+--> clearnet nginx --> HTTPS
blog -> www -> sync |
+--> onion nginx ----> Tor
The clearnet template listens on ports nginx and Certbot configure publicly. The onion template listens only on 127.0.0.1:8080; Tor is its intended client. If the onion vhost listened on a public interface, an external scan could find the same content on the server IP and make correlation with the onion service much easier.
The Tor Project's onion-service setup guide uses HiddenServiceDir for the service identity and HiddenServicePort to map the onion service to a local listener. My deployment writes those values into a Tor configuration drop-in, restarts the real Tor daemon unit, waits for the hostname file, and then installs the nginx onion vhost.
The clearnet nginx template also sends an Onion-Location header containing the configured onion hostname and nginx's $request_uri. The Tor Project's Onion-Location guide explains that Tor Browser accepts the header from an authenticated HTTPS page. Nginx defines $request_uri as the original URI including its arguments, so a clearnet request for a blog page advertises the same path and query on the onion service.
The Onion Address Is A Deployment Invariant
The onion hostname is derived from the service key. If the hidden-service directory is empty after a rebuild or a failed restore, Tor can generate a new identity. Nginx will still serve the site normally on its local socket, so the deployment can appear healthy while the onion address published on the front page reaches nothing.
I turned that silent failure into an explicit check. site.conf contains the expected onion hostname, and deploy-tor.sh compares it with the hostname Tor actually loaded. A mismatch stops the deploy and tells me to restore the correct key or deliberately update the published address.
The secret key never belongs in the repository. It is the ability to operate the onion identity, not an ordinary configuration value. The live copy stays in Tor's protected directory, and the backup is encrypted off the server. The .gitignore entries are a final guardrail, not the security boundary; once a secret enters public Git history, deleting the working-tree file is too late.
Why Certbot Runs Last
The clearnet deploy installs a plain HTTP nginx template and then runs Certbot. That order looks backwards until ownership of the configuration is clear.
The repository owns the HTTP baseline. Certbot's nginx plugin edits the installed vhost to add certificate paths, HTTPS, and the redirect. On every later deploy, the template replaces that installed file with the clean HTTP version again. Running Certbot before installing the template would apply its changes to a file that the deploy immediately overwrote.
The clearnet deployment script therefore installs and tests the vhost first, then runs Certbot with --nginx, --redirect, and --reinstall. The Certbot command reference documents those modes and flags.
Certbot failure is a warning rather than a failure of the content deployment. The fallback is the plain HTTP vhost that nginx already tested and loaded. This keeps a DNS or certificate-authority problem from taking the whole site down, but it also means there is a short HTTP-only window during every deploy and a longer one if Certbot fails. That is an explicit availability-versus-TLS tradeoff in the current design.
Security Boundaries
A static site is not automatically secure. It removes application code and a database from the request path, but nginx, the operating system, deployment credentials, DNS, TLS, Tor, and the content itself still have to be maintained.
The controls in this repository are small and specific:
try_filesreturns404instead of routing unknown paths to an application- nginx denies dotfile requests
server_tokens offavoids advertising the nginx version in normal responses- the onion vhost binds to loopback and disables unhelpful request logging
- the deployment validates rendered templates and nginx configuration
- the onion hostname check protects the published identity from silent replacement
- private onion keys and encrypted backups stay outside the public tree
- generated public content is separated from authored source and private notes
None of those controls replaces patching or monitoring. Their value is that each one has a narrow failure it is meant to prevent.
What I Kept Deliberately Simple
There is still no framework, no JavaScript runtime, and no general-purpose Markdown dependency. The front page remains hand-written because changing the domain should include reviewing the public identity, links, and contact copy. The blog generator is small enough to audit, and generated pages are committed so the public output can be reviewed before deployment.
The tradeoff is manual work. Adding images, richer markup, or a new metadata field means extending the renderer. Certbot temporarily makes the clearnet HTTP-only during deployment.
The point is not that every site should be built this way. The point is that this site has a small number of moving parts, and the boundaries between them are written down. I can trace a post from Markdown to HTML, from Git to the web root, and from each nginx listener to the network that should reach it.