I bought a VPS with a fairly simple plan: put a blog on it. What I ended up with is a small self-hosted platform that happens to run this blog but is really built so I can host a few small IT tools on subdomains later on.
Here's roughly how it came together, including a couple of things that tripped me up.
What I wanted out of it
A few things mattered to me going in. I wanted to manage the server through web dashboards rather than living in a terminal, since I'm not particularly fast on the command line and didn't want to be. I also knew from the start that the blog wasn't really the point — the point was having a platform I could keep adding small tools to without redoing the setup each time. And since this is a real server sitting on the public internet, it needed to be locked down properly, not secured as an afterthought.
What's running on it
Cockpit for general server admin, Docker to run everything in containers, Portainer to manage those containers, Nginx Proxy Manager for routing traffic and handling SSL, and Ghost for the blog itself. Tailscale sits underneath all of it, which I'll get to.
I went with Ghost instead of WordPress mainly to avoid the usual PHP/MySQL/plugin maintenance treadmill. Ghost is Node-based, has a clean writing interface, and there's an official Docker image for it, which fit with everything else being containerized anyway.
Running each tool as its own container was really the whole point of doing this on Docker in the first place — no dependency clashes between apps, easy to add or remove one without touching the rest, and the same repeatable steps each time: run the container, put it on the shared network, add it to the reverse proxy.
How it's wired together
DNS has a single wildcard record pointing every subdomain at the VPS, so I don't need to touch DNS again for each new tool. Nginx Proxy Manager listens on 80 and 443 and routes requests to whichever container matches the domain. All the containers that need to be reachable from the proxy sit on one shared Docker network, so NPM can talk to them by name instead of needing extra ports opened up. Ghost sits on the root domain, and future tools will each live on their own subdomain.
The thing that actually caught me out
I set up ufw to block public access to the admin panels — Cockpit, Portainer, and NPM's admin port — while leaving 80 and 443 open for the site itself. ufw said those ports were blocked. They weren't. I could still hit Portainer's login page from the public internet.
Turns out Docker manages its own iptables rules and inserts them ahead of ufw's, for any port a container publishes. ufw isn't wrong about what it thinks its rules are — Docker is just quietly letting traffic through underneath it regardless.
The actual fix had nothing to do with ufw. It was binding the container's port to one specific IP address instead of all interfaces:
ports:
- "100.x.x.x:9443:9443"
instead of:
ports:
- "9443:9443"
Once a port is bound to a specific address like that, Docker never opens it anywhere else, so there's nothing on the public interface left for ufw to worry about.
Tailscale for the admin side
Rather than maintaining a list of allowed IPs (which breaks the moment your home IP changes), I put Tailscale on the VPS itself. It gives the server a private address that's only reachable from devices logged into the same account. Combined with the port-binding fix above, Cockpit, Portainer, and NPM's admin panel are now bound only to that private address — invisible from the public internet, but still reachable from my own laptop or phone anywhere. 80 and 443 stay open to everyone, since that's what the site actually needs.
Where it's at now
tejjo.net resolves to Ghost over HTTPS with a Let's Encrypt certificate that renews itself. Every admin panel on the box is reachable only through Tailscale.
The blog was really just the first thing to go live on this. The reverse proxy, the shared network, and the wildcard DNS are all sitting there ready for whatever I build next — same pattern each time, just a new container and a new subdomain.
If you're setting up something similar: don't assume your firewall is doing what it says it's doing once Docker is in the mix. Worth checking.