Running 15 Services on One VPS With Docker Compose

At some point I looked at my server and counted fifteen containers. A reverse proxy, a git server, a CI runner, a game server, a mail server, a chat platform, a webmail client, a database, a VPN, an auth portal, an admin panel, a music bot, a TURN server, and a couple of static site builders. All on one four-core VPS with eight gigs of RAM.

It sounds like a mess, but it is actually pretty clean. The trick is structure.

One Compose File, Two Networks

Every service lives in a single docker-compose.yml. I tried splitting it into multiple compose files early on and it was a nightmare. Services that need to talk to each other end up on different networks, you forget which file defines what, and debugging becomes archaeology.

One file means one place to look. The trade-off is that the file is about 200 lines long, but that is way better than hunting through five separate files.

The networking is simple: two Docker bridge networks. The first one connects Caddy to the internet. The second one connects Caddy to every backend service. Services that need to be reached by Caddy join the backend network. Services that need direct port access to the internet get explicit port mappings.

The WireGuard container is the exception. It runs with host networking because it needs raw access to the network stack for the VPN tunnel. Everything else uses the bridge networks.

Container Naming

I name every container explicitly with container_name. Docker Compose generates names like wleeaf-caddy-1 by default, which is ugly in logs and hard to type in commands. Explicit names mean I can do docker logs caddy instead of docker logs wleeaf-caddy-1.

This also matters for DNS resolution inside Docker networks. When you give a container a name, other containers on the same network can reach it by that name. So my Caddy config just says reverse_proxy gitea:3000 and Docker handles the resolution.

Resource Reality

People overestimate how much RAM you need. Here is what my containers actually use:

The reverse proxy sits at 15 megabytes. The git server uses about 110. The mail server is the hungriest at 160. The Matrix homeserver varies between 100 and 500 depending on federation activity. The database backing it uses 70. Everything else is under 50 each.

Total memory usage hovers around 2.5 gigabytes. That leaves five gigs free for spikes, builds, and the occasional agent session eating a gig for a few minutes.

CPU is basically idle. The load average rarely goes above 0.1 unless someone is building a Docker image or running a CI job. Four cores is overkill for this workload.

Secrets Management

I keep secrets in a .env file next to the compose file. Docker Compose reads it automatically. Database passwords, API tokens, and hashed passwords all live there. The compose file references them with the dollar-sign-curly-brace syntax.

The .env file is not in any git repo. I back it up manually. Some people use Docker secrets or Vault for this, but for a single-server personal setup, a .env file is fine.

Restart Policies

Every service has restart: unless-stopped. This means containers come back after a reboot or a crash, but stay down if I explicitly stop them. I used to use always but that fights you when you are debugging, because the container keeps restarting while you are trying to read the logs.

The one exception is the CI runner. It has restart: unless-stopped too, but I sometimes need to stop it to free resources during heavy builds. With unless-stopped, it stays down until I bring it back up.

Volume Strategy

I mount named directories from the host filesystem, not Docker volumes. Every service gets a directory under the main project folder. Caddy gets ./caddy, Gitea gets ./gitea, the mail server gets ./stalwart, and so on.

This makes backups trivial. I can tar the entire project directory and I have everything: configs, data, and the compose file itself. Docker volumes are fine, but they hide the data in a location you have to look up every time.

The Upgrade Dance

Upgrading a service usually means changing the image tag and running docker compose up -d. For services using latest, I pull first with docker compose pull and then bring them up.

I do not use Watchtower or any automatic update tool. I want to know exactly when things change. An automatic update at 3 AM that breaks the mail server is not a problem I want to have.

The whole setup has been running for months with essentially zero unplanned downtime. The only time services go down is when I am actively working on them. For a personal infrastructure project, that is more than good enough.

← all articles wleeaf.dev →