- Python 45.1%
- CSS 23.4%
- JavaScript 13.7%
- HTML 8.9%
- Dockerfile 5.3%
- Other 3.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| data | ||
| docker | ||
| scripts | ||
| server | ||
| static | ||
| templates | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| build.py | ||
| docker-compose.yml | ||
| Makefile | ||
| README.md | ||
| requirements.txt | ||
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.pyissues 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.
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.
python3andmakeif 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.