How this site is built
A detailed look at the architecture, stack, and deployment model behind keithhinds.co.uk.
Overview
The site is built without frameworks: plain HTML, CSS, and vanilla JavaScript for every page. Content is static where it can be, and a small Node.js/Express API handles the blog. The whole thing runs on a Synology NAS in my home office, exposed to the internet via a Cloudflare Tunnel. No cloud compute, no monthly hosting bill.
I built it this way deliberately. Static HTML loads instantly, is trivially cacheable, and has no build pipeline to break. The infrastructure is under my control and costs only electricity to run. The Cloudflare Tunnel means the NAS never has an inbound port open, which keeps the attack surface small.
Infrastructure diagram
Simplified infrastructure diagram. Tailscale mesh not shown.
Full stack
| Layer | Technology |
|---|---|
| Front-end | Vanilla HTML5, CSS3, JavaScript (no framework). Shared CSS custom properties via css/shared.css. Shared nav JS via js/nav.js. |
| Web server | nginx:alpine inside Docker. Clean URLs via try_files $uri $uri/ $uri/index.html. Proxies /api/* to the Node.js container. |
| API | Node.js with Express. Handles blog post CRUD, image uploads, and the contact form. Runs in a separate container on port 3000. |
| Database | SQLite: a single posts.db file on a Docker volume. No separate database server needed. |
| Container host | Synology DS923+ NAS running Docker via Container Manager. Three containers: keith-website (nginx), keith-api (Node.js), keith-cloudflared (tunnel). |
| Networking | Cloudflare Tunnel (token-based) handles public ingress with no open inbound ports. Tailscale mesh for private admin access. |
| DNS / CDN | Cloudflare. SSL termination at the edge. Proxied A record pointing to the tunnel. |
| Deploy | Git push to GitHub, SCP changed files to NAS, rebuild Docker containers with docker compose up -d --build. |
Container setup
Three containers defined in a single docker-compose.yml. The nginx and API containers
share a Docker network so nginx can proxy to keith-api:3000 by name. The blog
database lives on a named volume mounted into the API container.
Why self-hosted?
Partly pragmatic, partly a learning exercise. Running infrastructure at home forces you to understand the whole stack (DNS, networking, containers, process management) rather than letting a PaaS abstract it away. I already had the NAS for backups, so the marginal cost of running a few extra containers is essentially zero.
The Cloudflare Tunnel approach is neat because it flips the traditional model: instead of the internet calling in, my NAS calls out to Cloudflare and holds an encrypted connection open. No inbound firewall rules needed, no dynamic DNS headaches, and Cloudflare handles SSL at the edge.
For a personal site, this is overkill in the best possible way. For anything handling sensitive data or requiring high availability, a managed platform would make more sense. But for a blog and portfolio, the reliability has been excellent.
Source code
The full source is on GitHub at github.com/khinds-dev/keith-website.