One Compose File, One Folder, One Front Door: Practical VPS Architecture
This is a write-up of how my VPS is organized, and why. The server hosts this website together with a few private services for personal use. There is no team behind it — I maintain it alone, in spare evening hours. That single fact drives every decision below: whatever I build has to be easy to reason about a year later, easy to back up, and easy to extend without re-learning everything first.
The way I work on it: I make the architectural decisions (what runs where, what is exposed to the internet, what requires backup), and an AI agent — Claude Code, running directly on the VPS — does the technical work: writing configs, debugging container networking, setting up services. The agent is fast at the “how”; the “what” and “why” stay human. This paper is about the “what” and “why”.
A note before we start: I describe services by their role, not by product name, port or path. A public post about your own server should teach the pattern. This should not serve as a reconnaissance report.
The big picture
Everything application-level runs in one Docker Compose stack: a single docker-compose.yml, with all persistent data in subfolders next to it (say, /opt/stack). The host itself stays almost empty. It runs sshd, ufw, fail2ban, unattended-upgrades, and that’s the complete list.
Internet
│
┌──────────────┼─────────────┐
│ :80 / :443 │ VPN (UDP) │ :22 (SSH, key-only)
▼ ▼ ▼
┌───────────┐ ┌─────────┐ (host sshd)
│ reverse │ │ VPN │
│ proxy │ └─────────┘ admin UIs: bound to
└─────┬─────┘ 127.0.0.1, reached
│ via SSH tunnel only
│ forwards by container name
│ inside the bridge network
┌────┴─────────┬──────────────┐
▼ ▼ ▼
this site monitoring log analytics
(static (uptime + (no JS tracker,
nginx) status page) no 3rd party)
The main services, by role:
- Reverse proxy — the single web entry point. It terminates TLS, holds the Let’s Encrypt certificates and forwards each domain to the right container.
- This website — a minimal nginx container serving the static build, deployed automatically from GitHub Actions.
- Private services — a VPN, uptime monitoring with a status page, and visitor analytics computed from the proxy’s access logs. Thus the site does not use JavaScript tracker and needs no cookie banner.
One Compose stack: the trade-off
The advantages. The entire server is described by one text file. Install services directly on the host and it slowly turns into a tangled knot of dependencies that no one dares to upgrade. With Compose, every service is isolated and can be upgraded or rolled back by changing an image tag. Best of all, the machine is reproducible: take a fresh VPS, install Docker, copy one folder, run docker compose up -d. Kubernetes solves the same problems for fleets of servers and teams of engineers; on a single personal VPS it would mostly just eat half the RAM.
The disadvantages. Everything shares one host, so a kernel problem or a full disk takes everything down at once. One compose file also represents a single point of risk during careless editing, although this risk is mitigated by the fact that containers are disposable and data is stored outside of them. Furthermore, Docker brings a famous firewall trap that I will discuss in the section dedicated to security.
How the services communicate
Two mechanisms and both deliberately boring.
A shared bridge network. All containers join one bridge network, where Docker provides DNS by service name: the proxy forwards requests to http://site:80 and never needs to know an IP. Internal services publish no ports to the outside world at all; they are reachable only through the proxy. Adding a new web service means one new compose entry and one proxy rule, not another open port.
Shared folders (bind mounts). Where a folder is enough, containers don’t need APIs to talk to each other. The proxy writes its access logs into a host folder, and the analytics container mounts the same folder read-only to compute statistics from it. The website works the same way: GitHub Actions rsyncs the built site into a host folder, and the nginx container serves that folder mounted read-only. New files go live instantly, without a restart. And even a compromised site container could not modify the site.
Security: one front door
The exposed surface is three ports: 80/443 (the reverse proxy), one UDP port (the VPN) and 22 (SSH — key-only, password authentication is disabled, fail2ban watches the rest). Everything else follows from a few rules.
- All web traffic enters through the reverse proxy. TLS termination, certificate renewal and access logging happen in one place. Bots scanning the internet see the proxy and nothing else.
- Admin panels are never public. Every admin UI is bound to
127.0.0.1and reached through an SSH tunnel (ssh -L <port>:127.0.0.1:<port> user@server). An admin login page that isn’t reachable is stronger than any password on one that is. - Know the Docker/ufw trap. Docker writes its own iptables rules and bypasses ufw entirely. “I closed that port in the firewall” simply does not work for published container ports. The reliable fix is binding to
127.0.0.1:portin the compose file — which is exactly why the admin UIs are configured that way. - The deploy identity is a machine, not me. GitHub Actions connects as a separate user with no sudo rights, a locked password and an SSH key restricted via
rrsyncto a single folder. The worst a stolen deploy key can do is overwrite the website files, and one workflow re-run restores those.
Backup: the whole server is one folder
My favorite property of the whole setup. All persistent data lives in bind mounts in subfolders next to the compose file, not in named volumes hidden somewhere under /var/lib/docker. The containers themselves are worthless; they can be recreated from images at any moment. Everything the server actually remembers — proxy configuration, certificates, VPN keys, monitoring history — sits in that one stack folder.
The backup strategy is therefore embarrassingly simple: restic encrypts the folder and ships it to Backblaze B2 every night. The website is not even part of the backup, because it can be reproduced from GitHub by re-running the deploy workflow.
The recovery test I keep in my head: new VPS, install Docker + ufw + fail2ban, restore the folder, docker compose up -d, point DNS at the new IP. That is the entire disaster-recovery plan. Most of the decisions above exist partly to keep it that short.
The commands I actually use
All from the stack folder:
docker compose up -d # apply the compose file (only changed services are recreated)
docker compose logs -f <name> # follow a service's logs
docker compose restart <name> # restart one service
docker compose pull && docker compose up -d # update images
docker ps # what is running
docker exec -it <name> sh # shell inside a container
docker network inspect <network> # who is on the network
Plus restart: unless-stopped on every service — after a reboot or a crash, everything comes back without me.
What I’d watch out for
Three lessons the server has already taught me:
- Pin your image tags. Upgrading to a new major version of my VPN image would have silently broken authentication had the
:latesttag been used. Major versions change behavior; upgrade when you decide to, not when a nightly pull decides for you. - Rotate container logs. Docker’s stdout logs grow forever by default. A few lines in
/etc/docker/daemon.jsoncap them per container. - Monitor from outside too. A monitoring container cannot report that its own server is down. An external checker watches the status page.
None of this is exotic, and that is the point. A one-person server survives on decisions that make every future problem smaller: one entry point, one folder to back up, one file that describes everything. The AI agent makes the setup fast; the architecture makes it survivable.