Building My Self-Hosted Data Portfolio

I wanted a portfolio that actually runs the things it talks about — not just screenshots of a database, but a real database, sitting on a server I own, that I can point queries at whenever I want. This post is a quick walkthrough of how that came together, and why I made the choices I did.
Why self-hosted
A few reasons pushed me away from just deploying to Vercel/Heroku/whatever and calling it done:
- It's more honest. A "database portfolio project" that lives in a free-tier managed Postgres instance isn't really showing off database skills — it's showing off following a tutorial. Running my own MySQL, handling my own backups, and dealing with my own uptime is the actual job.
- It's cheaper long-term. One box in a closet beats a stack of monthly SaaS bills.
- It forces me to learn the boring-but-critical stuff — networking, firewalls, systemd, container orchestration — that a PaaS quietly hides from you.
The stack
Everything runs in Docker Compose on an Ubuntu box, reachable only over Tailscale (plus one deliberately-punched hole for the portfolio site itself, via Tailscale Funnel):
- MySQL 8.4 — the database layer for everything, including this blog
- MinIO — S3-compatible object storage, for images and any file-based data
- Airflow — scheduled ETL, once the pipeline project is built
- JupyterLab — exploratory analysis
- Uptime Kuma — so I find out about outages before a recruiter does
A gotcha worth mentioning
The very first version of this stack had a boot-race bug: on reboot,
Docker would start before Tailscale had reacquired its IP address, so
every container publishing a port bound to that IP failed to come up.
Worse, a plain docker start afterward looked like it fixed things —
the process was running — but the network endpoint was silently broken.
The fix was a small systemd unit that waits for the Tailscale interface
before running docker compose up -d --force-recreate. Not glamorous, but
it's the kind of thing you only learn by actually operating a server
instead of reading about operating one.
How the blog itself works
This post you're reading right now is proof the pipeline works end to end:
INSERT INTO portfolio.blog_posts (slug, title, content, status, published_at)
VALUES ('my-slug', 'My Title', '# Markdown here', 'published', NOW());
That's it — no CMS, no admin panel. I write Markdown, I INSERT it into
MySQL, and the site renders it on the next request. Images go into MinIO
and get proxied through the app itself, so the object storage server never
has to be exposed to the internet directly.
If the whole point of this site is to demonstrate that I can run real infrastructure, then the infrastructure behind the writing about it should be real too.
What's next
- Finish Project 1 (normalizing a flat jobs dataset into 3NF) and write it up here
- Build the Airflow pipeline for Project 2
- Eventually: a proper backup/restore runbook, once Project 3 stress-tests the schema at scale
More soon.