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

Visitor Browser HTTPS Cloudflare DNS + CDN SSL termination Tunnel Synology NAS · Docker Compose cloudflared Tunnel container nginx:alpine Static HTML · :80 /api/* Node.js API Express · :3000 SQLite DB

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.

services: keith-website: # nginx — serves static HTML build: . ports: ["8080:80"] networks: [site-net] keith-api: # Node.js/Express — blog API build: ./api expose: ["3000"] volumes: [blog_data:/data] networks: [site-net] keith-cloudflared: # Cloudflare Tunnel image: cloudflare/cloudflared command: tunnel run environment: TUNNEL_TOKEN: <token> networks: [site-net] volumes: blog_data: networks: site-net:

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.