One repo for every static site you host
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.