My music landing page, protected by Anubis and deployed in Docker. https://joshquinlan.co.uk
  • Python 45.1%
  • CSS 23.4%
  • JavaScript 13.7%
  • HTML 8.9%
  • Dockerfile 5.3%
  • Other 3.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-29 16:49:08 +01:00
data Update artists 2026-07-29 16:49:08 +01:00
docker infra: run the whole site as one Docker container 2026-07-18 00:54:53 +01:00
scripts site: rebuild as a hand-rolled static site 2026-07-17 19:18:09 +01:00
server contact: deliver enquiries via the SparkPost API 2026-07-18 00:54:42 +01:00
static docs: catch the README up with the deployed reality 2026-07-17 23:45:56 +01:00
templates contact: gate the form with a self-hosted ALTCHA check 2026-07-17 22:05:19 +01:00
.dockerignore infra: run the whole site as one Docker container 2026-07-18 00:54:53 +01:00
.env.example infra: run the whole site as one Docker container 2026-07-18 00:54:53 +01:00
.gitignore infra: run the whole site as one Docker container 2026-07-18 00:54:53 +01:00
build.py contact: gate the form with a self-hosted ALTCHA check 2026-07-17 22:05:19 +01:00
docker-compose.yml infra: run the whole site as one Docker container 2026-07-18 00:54:53 +01:00
Makefile infra: run the whole site as one Docker container 2026-07-18 00:54:53 +01:00
README.md docs: document the homelab container workflow 2026-07-18 00:54:53 +01:00
requirements.txt site: rebuild as a hand-rolled static site 2026-07-17 19:18:09 +01:00

joshquinlan.co.uk

Hand-rolled static site. build.py renders the templates in templates/ and the data in data/artists.yaml into public/, copying everything under static/ as-is. No analytics and no third-party requests at runtime: fonts are self-hosted, and so is every part of the protection stack below. The only JavaScript is a small progressive enhancement for the contact form and the vendored ALTCHA widget it uses.

The site runs as a single Docker container in the homelab, behind a reverse proxy that terminates TLS.

Layout

build.py              generator (Python 3, PyYAML only)
data/artists.yaml     artist categories, the source of truth
templates/            base, home, artists and 404 templates
static/               CSS, fonts, favicons, robots.txt,
                      PGP key (served as /key.asc), vendored
                      ALTCHA widget under js/ and css/
server/formhandler.py contact form service (stdlib only)
server/test_formhandler.py  its test suite (`make test`)
scripts/gen_favicons.sh  regenerates the favicon set from
                      static/favicon.svg
docker/               Dockerfile, nginx, Anubis and
                      supervisor config for the container
docker-compose.yml    the one service
public/               build output, never edited by hand

What runs in the container

One container, one init, three supervised processes. The reverse proxy in front terminates TLS and forwards to nginx on port 8080:

reverse proxy (TLS)
  -> nginx :8080        headers, scraper 403, redirects
    -> Anubis :8923     challenges automated traffic
      -> nginx :8924    serves the files, proxies /contact
        -> formhandler :8081   validates and sends mail

Only 8080 is reachable; Anubis, the internal vhost and the form handler bind loopback inside the container.

Protection stack

The site asks not to be indexed or archived, and enforces as much of that as it can:

  • robots.txt disallows everything, each page carries a robots meta tag, and nginx sends an X-Robots-Tag header on every response. robots.txt only asks politely; the next two layers enforce.
  • nginx refuses known AI scraper user agents with a 403 before anything is served (the map is in docker/nginx.conf).
  • Anubis sits between nginx and the site content and challenges traffic that looks automated with a proof of work. The policy is challenge-bots-only: ordinary browsers are never challenged; self-declared crawlers, headless browsers and programmatic clients have to spend CPU first, and AI scrapers are denied outright (docker/anubis/botPolicies.yaml).
  • The contact form requires a self-hosted ALTCHA proof-of-work solution on top of the honeypot field. The widget (static/js/altcha.min.js, MIT, pinned) runs entirely on this origin; server/formhandler.py issues and verifies the challenges itself, signed with a secret supplied by the environment and kept out of the repository.

HSTS is deliberately not sent by the container: the reverse proxy terminates TLS and owns that header. Set it there.

Mail

The form delivers through the SparkPost transmissions API (EU host). Enquiries are sent from a no-reply address whose domain authorises SparkPost in SPF, with the enquirer in Reply-To, to the address in FORM_RECIPIENT. No mail server is involved.

Spinning it up

Prerequisites

  • Docker with the Compose plugin, on the homelab host.
  • python3 and make if you want to build or test the site outside the container (optional; the image builds it too).
  • The reverse-proxy network already exists on the host: citadel-routing. The container joins it so the proxy can reach it by name; no host port is published.

Secrets

Copy the example env file and fill it in. .env is gitignored and never committed:

cp .env.example .env
$EDITOR .env

Set SPARKPOST_API_KEY (EU account) and FORM_ALTCHA_KEY (a long random value, e.g. openssl rand -hex 32). The recipient and sender have sensible defaults; override them in .env if needed.

Build and run

make image     # docker compose build
make up        # docker compose up -d
make logs      # follow the logs
make down      # stop and remove

The static site is rendered inside the image at build time, so make build is not a prerequisite. Rebuild after any content or config change:

make image && make up

The compose build uses host networking and the container resolves through 9.9.9.9, matching the homelab setup; adjust in docker-compose.yml if yours differs.

Point the reverse proxy at it

The container listens on 8080 and is reachable on the citadel-routing network as joshquinlan-co-uk:8080. Both proxies must forward the real client IP (X-Forwarded-For) and the scheme (X-Forwarded-Proto), or Anubis will see the proxy's address instead of the visitor's.

Public access, Pangolin: add a resource for joshquinlan.co.uk with the target http://joshquinlan-co-uk:8080 (the Pangolin/Traefik container must share the citadel-routing network). Pangolin forwards the standard proxy headers and terminates TLS, so enable HSTS there.

LAN access, Nginx Proxy Manager: add a Proxy Host with the scheme http, forward hostname joshquinlan-co-uk, forward port 8080, and Block Common Exploits off (the container does its own filtering). NPM forwards X-Forwarded-For and X-Forwarded-Proto by default.

Local preview without the container

make build
make serve

Then open http://localhost:8000/. The contact form renders but has nothing to post to locally; everything else works.

Tests

make test

Runs the form handler suite (delivery is stubbed, no mail is sent), including the ALTCHA challenge and verification cases.

How the image is built

A multi-stage build: the first stage renders the static site with Python and PyYAML; the runtime stage installs nginx, Anubis (pinned by version and per-architecture sha256, built for amd64 or arm64 from TARGETARCH), python3 and supervisor, then copies the rendered site and the config in. The form handler is stdlib-only, so the runtime carries no Python packages.