I've been writing software for more than twenty years. I started on a ZX Spectrum, talked my way into the industry in Romania before there was a clear path into it, started two companies, and these days I run a third. In all that time, one preference has never changed: I like to own the thing I build on.
So when I rebuilt this site, I didn't reach for a platform that hides the server from me. I rented a server and put the whole thing on it myself. This is a tour of that stack, what runs where, and the one bug that cost me an afternoon so it doesn't cost you one.
The shape of it
The site is a Next.js app (App Router, React 19) talking to a MariaDB database through Prisma. The articles you're reading aren't Markdown files in a repo. They're rows in a database, rendered as HTML. That's a deliberate choice I'll defend in a later post. For now, just know there's a real database behind this page.
All of it runs in Docker on a single Hetzner server, behind Traefik, which handles routing and TLS for several of my apps on the same box. Code ships through GitHub Actions. The database backs itself up to Cloudflare R2 every night. No PaaS, no per-seat dashboard, no surprise bill. One server I understand completely.
Next.js in a container, done lean
The app builds as a Next.js standalone output inside a multi-stage Dockerfile. The pattern matters: one stage installs dependencies, one builds the app, and a final slim runner stage copies only what production needs, namely the standalone server, the static assets, the Prisma schema, and an entrypoint script. The result is a small image running as a non-root user, with nothing from the build toolchain left behind.
On every boot, the container doesn't just start the server. An entrypoint script waits for the database to accept connections, runs prisma migrate deploy to apply any pending schema migrations, and then starts Next.js:
until nc -z mysql 3306; do sleep 1; done
prisma migrate deploy
exec "$@"
migrate deploy is idempotent. Only unapplied migrations run, so it's safe on every single boot and creates the tables from scratch on a fresh database. Schema is automatic. The actual article rows I seed once, by hand, after the first deploy. Schema and data have different lifecycles, and I treat them that way.
The bug that cost me an afternoon
Here's the war story, because it's the most useful thing in this post.
I'd deployed four apps to this server without trouble. The fifth, this site, went up, CI was green, and the browser showed me a 526 error from Cloudflare. A 526 means the origin's TLS certificate couldn't be validated. So naturally I went hunting through Traefik config, certificates, Cloudflare's SSL mode, the network setup. Everything looked right.
The real cause was none of those. It was one missing line in the Dockerfile.
A Next.js standalone server reads the HOSTNAME environment variable to decide which interface to bind to. Docker automatically sets HOSTNAME to the container's ID. So without an override, Next binds only to that container-ID interface, not to 0.0.0.0. The healthcheck hits 127.0.0.1:3000 and gets refused. The container is marked unhealthy. Traefik skips unhealthy containers, so no router is created. With no router, Traefik serves its own default self-signed certificate. Cloudflare sees that and returns 526.
A certificate error, four layers downstream of the actual problem. The fix is a single line:
ENV HOSTNAME=0.0.0.0
The first four apps had it because they were built fresh from a template that included it. This one reused an older Dockerfile from a pre-Traefik setup, and the line had quietly never been there. The lesson I wrote down for myself: when a symptom points at the network or the certificate, check that the app is actually listening where you think it is first. Running docker exec <app> wget -qO- http://127.0.0.1:3000/api/health would have told me in five seconds.
One server, several apps
Because Traefik fronts multiple apps on this box, each app declares its own routing through Docker labels rather than a central config file. This site's labels tell Traefik to serve robert.bojor.dev over HTTPS with a Let's Encrypt certificate, and to send traffic to port 3000.
There's a subtlety here that bit me too. This app lives on two Docker networks: a public web network where Traefik reaches it, and a private internal network where it reaches MariaDB. When a container is on two networks, Traefik can't guess which one to use, so you have to tell it explicitly:
- "traefik.docker.network=web"
The database, by contrast, sits only on the internal network. Its port is never published to the host, never exposed to the internet. The only things that can talk to it are the app and the backup job, over a private network. Its data lives on a host bind mount, so it survives any container or image being thrown away and replaced.
Shipping code without touching the server
I don't SSH in to deploy. I push to main.
A GitHub Actions workflow builds the Docker image for the server's architecture, pushes it to the GitHub Container Registry tagged with both latest and the commit SHA, then copies the compose file to the server and runs three commands over SSH: pull the new image, bring the stack up, prune the old image. That's the entire deploy. The compose file on the server is overwritten by CI every time, so there's a single source of truth and no temptation to hand-edit production into a state nobody can reproduce.
Tagging images by commit SHA means a rollback is just pointing at an earlier tag. No rebuild, no panic.
Backups I never have to think about
A site is only as safe as its last backup, and a backup you have to remember to run is not a backup. So a small container sits alongside the database and does nothing but dump it once a day at 3 a.m., compress it, and push it to Cloudflare R2. It keeps a local copy for fast restores and prunes the on-host copies after thirty days. The authoritative long-term retention is a lifecycle rule on the bucket itself.
It talks to the database over that same private internal network, and to R2 over TLS that it actually verifies. Off-site, automatic, encrypted in transit. I don't think about it, which is exactly the point.
Why bother?
You could deploy a site like this to a platform in an afternoon, and for many people that's the right call. I'm not here to tell you that you're wrong for using one.
But I keep coming back to ownership. When something breaks at 11 p.m., I can see every layer: the container, the proxy, the network, the database, the backup. Nothing is hidden behind a dashboard I don't control or a bill that scales with someone else's roadmap. That 526 error was frustrating, but I could trace it all the way down precisely because I own all the way down.
Twenty years in, that's still the part of this work I enjoy most: understanding the whole machine, top to bottom, and being responsible for it. This little corner of the internet is mine. I built it, I run it, and I know exactly how it works.
In the next few posts I'll go deeper into individual pieces: the database-as-content choice, the CI/CD pipeline, and that backup container in detail. If there's a layer you want me to pull apart first, tell me.