Viktor Vojtek

/2 min read

Ten side projects, one VPS

How I moved everything I run onto a single server with Docker Compose and nginx, and the four mistakes that were waiting for me.

Over the years my side projects had spread across a laptop, a Mac mini under a desk and a couple of free tiers that were about to stop being free. In August I moved all of them onto one 12 GB VPS. This is the shape that came out of it, and the things that bit.

The shape

Every project gets a directory under /srv/apps/<name> and its own docker-compose.prod.yml. Databases live inside the compose network with no published ports at all. The app itself publishes exactly one port, and always on loopback:

services:
  app:
    build: .
    restart: unless-stopped
    ports:
      - "127.0.0.1:3103:3000"

A single host nginx is the only public listener. Each app gets a vhost file with a server_name, and certbot's nginx plugin adds the certificate and the redirect. Renewal is the stock systemd timer; there is nothing to remember.

Deploys are one line, recorded per project in a small ledger repository along with the port, the env var names (never values) and any build-fix notes:

cd /srv/apps/<name> && git pull && docker compose -f docker-compose.prod.yml up -d --build

GitHub allows one deploy key per repository, so the server holds one key per project and an SSH alias per key. It is more files than a personal access token, and it means a leaked key unlocks one read-only repo, not the account.

What bit

Published Docker ports bypass ufw. Docker writes its own iptables rules ahead of the firewall, so a compose file with "3000:3000" is on the internet no matter what ufw says. The only protection is the 127.0.0.1: prefix on every ports: line. I now audit docker ps for 0.0.0.0 after every deploy.

Prisma and Alpine do not get along. Two of the apps use Prisma, and its query engine wants glibc. Every image that touches Prisma is built from node:24-slim, not node:24-alpine. The size difference is not worth the afternoon.

NEXT_PUBLIC_* and VITE_* are build-time. They are baked into the bundle when the image is built, so a domain switch means a rebuild unless the app uses relative paths. Now every frontend talks to /api, and switching a domain is an nginx change only.

Build on the server. Images built on an ARM Mac carry ARM Prisma engines and native modules. The server is x86. Pushing images from the laptop looked like a time saver and cost more than it saved. The server has six cores; it can build.

Was it worth it

Nine apps, four domains with certificates, nightly database dumps and a reboot test that brought every container back in under a minute. The whole thing costs less than the free tiers were about to. And the ledger repo turned out to be the most valuable artifact: a place where every decision about the server is written down once.

  • infrastructure
  • docker
  • nginx