This post is the dev log for the site you're reading right now. I wanted to document the decisions I made, the problems I ran into, and what I'd do differently — partly for anyone reading this, partly so future me has a reference.
Why build it from scratch?
I could have used a static site generator or a hosted platform. I chose not to because the point of this site is to learn by doing. Every part of the stack is something I wanted to understand properly, not just configure.
The Stack
Backend: Python and Flask. Straightforward for a personal site, well-documented, and I know it well enough to move fast without fighting the framework.
Database: PostgreSQL on the same VPS. Started with SQLite during development, migrated to Postgres before going live. Alembic handles migrations via Flask-Migrate.
Server: Hetzner CX23 — 2 vCPU, 4GB RAM, 40GB NVMe, around £3.20/month. Nginx sits in front of Gunicorn as a reverse proxy. Gunicorn runs 2 workers.
Networking: Cloudflare Tunnel. The server has no open inbound ports — cloudflared runs as a service and maintains a persistent outbound tunnel to Cloudflare's edge. This means the origin IP is never exposed. Cloudflare handles TLS termination, WAF, and DDoS protection before anything reaches the server.
Security Decisions
Security wasn't an afterthought. A few things I'm particularly happy with:
- No open inbound ports. The Cloudflare Tunnel means UFW only needs to allow outbound traffic. There is nothing to port-scan on this server.
- Scrypt password hashing via Werkzeug. Slower and more memory-hard than bcrypt, which makes brute-force attacks more expensive.
- Server-side session tokens. Flask-Login tracks sessions in the database with a token validated on every request. Sessions can be individually revoked from the admin panel. If a token is stolen, it can be killed server-side.
- Magic bytes image validation. File uploads check the actual file header bytes,
not just the extension. A renamed
.exewon't pass. - CSP, HSTS, Permissions-Policy headers on every response. HSTS with a 2-year max-age and includeSubDomains. Permissions-Policy denies camera, mic, geolocation, and everything else a portfolio site has no business using.
- Rate limiting on login (5 per minute, moving window) and image uploads. Moving window prevents burst attacks at fixed-window boundaries.
- CSRF protection on every form via Flask-WTF, with the token also injected into fetch() calls via a patched global.
The Admin Panel
The site has a full admin panel at /admin. Features include:
- Post and project CRUD with Markdown support, live preview, and draft/publish toggle
- Bulk publish, unpublish, and delete with confirmation modals
- RBAC — admin and viewer roles. Viewers can read but not write.
- Audit log tracking every significant action with timestamp, user, and IP
- Session management — view all active sessions, revoke individually or all at once
- System monitor showing live CPU, memory, disk, and network I/O via psutil
- GitHub OAuth integration for a repo picker on the project creation form
- About page editor with drag-to-reorder stack items and skills
Testing
107 automated tests written with pytest. 16 test classes covering:
- All public routes and authentication flows
- Post and project CRUD
- User management and access control
- Security headers on every response
- XSS sanitisation via nh3
- Image upload validation including magic bytes and file size
- RSS feed XML escaping
- Sitemap generation
- Postgres-specific behaviour including unicode, long content, and cascade deletes
Deployment
Two branches — dev for active development, main for live production. Merging dev
into main and pushing triggers a deploy script on the server that pulls, installs
dependencies, runs migrations, and restarts the service. set -e means the script
aborts immediately if any step fails.
What I Would Do Differently
The unsafe-inline in the CSP script directive is the thing I'm least happy with.
Removing it properly requires nonces on every inline script block across every template
— that's a bigger refactor I'll tackle in a future version.
I'd also set up log shipping earlier. Right now logs rotate locally with logrotate but aren't shipped anywhere. Adding something like Loki or a simple remote syslog would make debugging production issues much easier.
What's Next
The site is the first public-facing project from what will eventually be a full home lab setup. The plan is to move hosting to self-managed hardware once the lab is ready, keeping Cloudflare Tunnel in front of it. More on that as it develops.