pr1v8.ca

One repo for every static site you host

· 3 min read · nginxself-hostingrsync

If you self-host for long enough you end up with a pile of small websites: a landing page, a joke site a friend asked for in 2013, a mirror of some web toy, a redirect or two. Each one was set up on a different afternoon, so each has its own folder, its own nginx config written from memory, and its own idea of what a 404 looks like. None of it is in version control.

This post describes the layout I moved mine into. It is nothing clever. The point is that the layout is small enough to remember, and that redeploying is one command.

The layout

sites/<domain>/              the web root, deployed verbatim
sites/<domain>/.deploy-keep  directories that live only on the server
nginx/snippets/common.conf   shared hardening, included by every vhost
nginx/snippets/php.conf      the PHP-FPM handler, for the one site that needs it
nginx/sites/<domain>.conf    one vhost per domain
deploy.sh                    rsync + nginx -t + reload

Everything a browser fetches is under sites/. Everything nginx reads is under nginx/. A site that is only a redirect has a vhost and no folder.

Keep big files out of git without losing them

Two of my sites carry media that has no business in a repository: a hundred megabytes of audio and a mirror of a web toy with its own asset packs. They stay on the server. The folder they live in is named in the site’s .deploy-keep file, one path per line:

media/

The deploy script turns each line into two rsync arguments:

args=(-az --delete --exclude=.deploy-keep)
while IFS= read -r keep; do
  [ -n "$keep" ] || continue
  args+=("--exclude=$keep" "--filter=protect $keep")
done < "sites/$s/.deploy-keep"
rsync "${args[@]}" "sites/$s/" "server:/srv/www/$s/"

--exclude stops rsync from uploading the directory. --filter=protect stops --delete from removing it on the receiving side because it is missing from the source. You need both. With only the exclude, the first --delete run wipes the media. I tested that on a copy before trusting it, and so should you.

One snippet for the boring parts

Every vhost includes the same file:

autoindex off;
server_tokens off;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
error_page 404 /404.html;
location = /404.html { internal; }
location ~ /\. { deny all; }

autoindex off is the one that mattered to me. One of my old vhosts had it on, so a folder I had forgotten about was a browsable listing on the public internet. If you take one thing from this post, grep your configs for autoindex.

A vhost then becomes a few lines:

server {
    listen 80;
    server_name example.com;
    root /srv/www/example.com;
    index index.html;
    include snippets/common.conf;
    location / { try_files $uri $uri/ $uri.html =404; }
}

The $uri.html lets /about serve about.html, so pages do not need a folder each.

The deploy script

Before anything is overwritten, the live vhost directory is copied to a timestamped backup next to it. Then the confs are uploaded and nginx -t runs inside the container. If that fails, the script stops and nothing has been reloaded; the old confs are still in the backup. Only after the test passes does it sync the site folders and reload.

That ordering is what makes it safe. A typo in a vhost cannot take the sites down, and a bad sync is one rsync from the backup away from fixed.

What I left out

There is no build step for the plain sites and I intend to keep it that way. The blog you are reading is the one exception: a hundred lines of Python turn Markdown into HTML into the same sites/ folder, and the deploy script runs it first. No framework, no node_modules, nothing to upgrade.