jguillaumesio
prod-opsdevopsdocker

Shipping a SaaS on one VPS with Docker Compose: an honest audit

The whole product runs on a single VPS behind one docker-compose.yml. Here is why that was the right call at launch, and an honest map of every single point of failure it hides.

The entire product runs on a single VPS. One machine, one docker-compose.yml, a containerised nginx in front, and a make deploy I run over SSH. It serves real, paying customers, and it has never been down for more than a few minutes. It is also, by any serious production standard, held together with tape.

I built this SaaS end to end for a small agency: a marketplace that connects brands with creators. Solo. The infrastructure reflects that. Every choice optimised for “one person ships this next week,” not “this survives a datacentre fire.” This article is the honest audit: what the setup is, why it was the right call at launch, and a full map of everything that can take it down.

It is also the start of a series. Each single point of failure below becomes its own article, built around the real incident that forced me to fix it. This is part 0: the state of the system before any of that hardening.

Why one VPS and Docker Compose was the right call

I want to defend this setup before I tear it apart, because the instinct to reach for Kubernetes on day one is how solo projects die. You spend your runway operating a cluster instead of shipping the features that decide whether the product lives.

One VPS and one compose file buys you the thing that actually matters early: a single mental model. Everything lives in one file. One machine you can SSH into and reason about end to end. And Docker Compose is not a toy: it gives you named networks, service dependencies, healthchecks, and log rotation out of the box. For a product finding its first customers, that is genuinely enough.

The mistake is not starting here. The mistake is staying here without knowing exactly where the sharp edges are. So here they are.

The actual architecture

Six services, one network, nginx as the only public entrypoint:

# docker-compose.yml (trimmed and anonymised)
services:
  nginx:      # reverse proxy + TLS, the only thing exposed to the internet
    image: nginx:1.24
    ports: ['80:80', '443:443']
    restart: no
  api:        # Bun + Express, Postgres + Redis/BullMQ
    build: { context: ., dockerfile: apps/api/Dockerfile }
    ports: ['8000:8000']
    restart: no
  db:         # Postgres 15, data in a local bind mount
    image: postgres:15
    ports: ['127.0.0.1:5432:5432']
    volumes: ['./postgres/data:/var/lib/postgresql/data']
    restart: no
  redis:
    image: redis:7
  storage:    # MinIO, S3-compatible object storage
    ports: ['9000:9000', '9001:9001']   # 9001 is the admin console
    restart: no
  dashboard:  # React + Vite, built to static files served by nginx
    build: { context: ., dockerfile: apps/dashboard/Dockerfile }

The dashboard is a React single-page app served as static files. The API is a Bun and Express monorepo talking to Postgres, Redis, and a BullMQ queue. MinIO holds uploads. Persistent data (Postgres, MinIO) sits in bind mounts on the host filesystem.

Read that compose file with a critical eye and the problems are already visible. Two are worth flagging now because they are the good habit and the bad habit sitting side by side. Postgres binds to 127.0.0.1:5432, so it is not reachable from the internet: that is the right reflex. The MinIO admin console on 9001 is published to every interface: that one is a hole, and it even carries a TODO in the real file. More on that in the security article.

How a deploy works today

There is no pipeline. A deploy is me, on the server, typing:

# on the production server, over SSH
git pull
make deploy SERVICE=api
# which runs, roughly:
#   docker compose build api           # builds the image ON the prod box
#   docker compose up -d --force-recreate api

Two things are wrong with this, and both get their own article. The image is built on the same machine that is serving live traffic, so the build competes with customers for CPU, RAM, and disk. And --force-recreate means the container stops and restarts, so every deploy is a few seconds of downtime with nothing catching the requests in between.

It works. It is also exactly the kind of “works until it doesn’t” that this series is about.

The map: every single point of failure

None of these are hypothetical. Each one bit me, and each one is the hook for its own article.

The weak spotWhy it hurtsCovered in
Build runs on the prod boxThe build filled the disk and took production downArticle 1: the disk that killed prod
Docker and the host firewall disagreeDocker writes its own iptables rules, and a port I thought was blocked was open to the internetArticle 2: Docker bypasses your firewall
Tests exist but never runReal regressions shipped while the unit tests sat unused in the repoArticle 3: tests nobody ran
Nothing protects mainA Friday git push deployed straight to prod, untested, with downtimeArticle 4: ban commits on main
restart: no on every serviceA crash at night was never restarted, including the databaseArticle 5: when Docker watches prod die
No status view, logs only over SSHDuring an incident I was grepping JSON in a terminal to find out what brokeArticle 6: a status dashboard and centralised logs
Errors die in a log fileUsers reported 500s by email before I ever saw themArticle 7: real error tracking
Constant attacks, a certificate that silently expiredScans and brute-force all day, plus a renewal cron that ran a broken pathArticle 8: CrowdSec, and Cloudflare as the next layer
Bind mounts, no backupA pgdata directory got wiped, with no volume and no backup behind itArticle 9: backups that actually restore
One machine, and a client requestThe client wanted preprod wired to production dataArticle 10: when the client wants prod data in preprod

Where this series goes

The thread through all ten is the same: harden a solo-built SaaS without reaching for Kubernetes and without blowing a small agency’s budget. Everything stays on a VPS you can afford. The goal is not a perfect platform. It is a production you can sleep through.

I will link each article here as it publishes, so this page is the map you can come back to. Next up is the one with the best story and the sharpest lesson: the day a docker compose build filled the disk and took the whole thing down.