Faire tourner un SaaS sur un seul VPS avec Docker Compose : l'audit honnête
Tout le produit tourne sur un seul VPS derrière un docker-compose.yml. Voici pourquoi c'était le bon choix au lancement, et la carte honnête de chaque point de défaillance unique qu'il cache.
Tout le produit tourne sur un seul VPS. Une machine, un docker-compose.yml, un nginx conteneurisé devant, et un make deploy que je lance en SSH. Il sert de vrais clients qui paient, et il n’est jamais tombé plus de quelques minutes. Il tient aussi, selon tout standard de production sérieux, avec du scotch.
J’ai construit ce SaaS de bout en bout pour une petite agence : une marketplace qui met en relation des marques et des créateurs. En solo. L’infrastructure le reflète. Chaque choix optimisé pour “une personne met ça en prod la semaine prochaine”, pas pour “ça survit à l’incendie d’un datacentre”. Cet article est l’audit honnête : ce qu’est le setup, pourquoi c’était le bon choix au lancement, et la carte complète de tout ce qui peut le tuer.
C’est aussi le début d’une série. Chaque point de défaillance unique ci-dessous devient son propre article, construit autour de l’incident réel qui m’a forcé à le corriger. Voici la partie 0 : l’état du système avant tout ce travail de durcissement.
Pourquoi un seul VPS et Docker Compose était le bon choix
Je veux défendre ce setup avant de le démonter, parce que le réflexe de sortir Kubernetes dès le premier jour, c’est comme ça que les projets solo meurent. Vous brûlez votre trésorerie à exploiter un cluster au lieu de livrer les fonctionnalités qui décident si le produit survit.
Un VPS et un fichier compose vous achètent la seule chose qui compte vraiment au début : un modèle mental unique. Tout vit dans un fichier. Une machine dans laquelle vous pouvez entrer en SSH et raisonner de bout en bout. Et Docker Compose n’est pas un jouet : il vous donne des réseaux nommés, des dépendances entre services, des healthchecks et la rotation des logs, prêts à l’emploi. Pour un produit qui cherche ses premiers clients, c’est largement suffisant.
L’erreur n’est pas de commencer ici. L’erreur est d’y rester sans savoir précisément où sont les bords tranchants. Alors les voici.
L’architecture réelle
Six services, un réseau, nginx comme unique point d’entrée public :
# docker-compose.yml (allégé et anonymisé)
services:
nginx: # reverse proxy + TLS, la seule chose exposée à 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, données dans un bind mount local
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, stockage objet compatible S3
ports: ['9000:9000', '9001:9001'] # 9001 est la console d'admin
restart: no
dashboard: # React + Vite, compilé en fichiers statiques servis par nginx
build: { context: ., dockerfile: apps/dashboard/Dockerfile }
Le dashboard est une application React monopage servie en fichiers statiques. L’API est un monorepo Bun et Express qui parle à Postgres, Redis et une file BullMQ. MinIO stocke les uploads. Les données persistantes (Postgres, MinIO) vivent dans des bind mounts sur le système de fichiers de l’hôte.
Lisez ce fichier compose avec un œil critique et les problèmes sautent déjà aux yeux. Deux méritent d’être signalés tout de suite, parce que ce sont la bonne habitude et la mauvaise assises côte à côte. Postgres écoute sur 127.0.0.1:5432, donc il n’est pas joignable depuis internet : c’est le bon réflexe. La console d’admin MinIO sur 9001 est publiée sur toutes les interfaces : celle-là est un trou, et elle porte même un TODO dans le vrai fichier. On en reparle dans l’article sécurité.
Comment se passe un déploiement aujourd’hui
Il n’y a pas de pipeline. Un déploiement, c’est moi, sur le serveur, qui tape :
# sur le serveur de production, en SSH
git pull
make deploy SERVICE=api
# ce qui exécute, en gros :
# docker compose build api # construit l'image SUR la machine de prod
# docker compose up -d --force-recreate api
Deux choses clochent ici, et chacune a son article. L’image est construite sur la machine même qui sert le trafic en direct, donc le build entre en concurrence avec les clients pour le CPU, la RAM et le disque. Et --force-recreate veut dire que le conteneur s’arrête et redémarre, donc chaque déploiement est quelques secondes d’indisponibilité, sans rien pour rattraper les requêtes entre-temps.
Ça marche. C’est aussi exactement le genre de “ça marche jusqu’à ce que ça casse” dont parle cette série.
La carte : chaque point de défaillance unique
Aucun n’est hypothétique. Chacun m’a mordu, et chacun est le point de départ de son propre article.
| Le point faible | Pourquoi ça fait mal | Traité dans |
|---|---|---|
| Le build tourne sur la machine de prod | Le build a rempli le disque et fait tomber la production | Article 1 : le disque qui a tué la prod |
| Docker et le pare-feu de l’hôte se contredisent | Docker écrit ses propres règles iptables, et un port que je croyais bloqué était ouvert sur internet | Article 2 : Docker contourne ton pare-feu |
| Les tests existent mais ne tournent jamais | De vraies régressions livrées pendant que les tests unitaires dormaient dans le repo | Article 3 : les tests que personne ne lançait |
Rien ne protège main | Un git push un vendredi déployait direct en prod, sans test, avec downtime | Article 4 : interdire le commit sur main |
restart: no sur chaque service | Un crash la nuit n’était jamais redémarré, y compris la base de données | Article 5 : quand Docker regarde ta prod mourir |
| Aucune vue de statut, les logs seulement en SSH | Pendant un incident, je grepais du JSON dans un terminal pour comprendre ce qui cassait | Article 6 : un dashboard de statut et des logs centralisés |
| Les erreurs meurent dans un fichier de log | Les utilisateurs signalaient les 500 par email avant que je les voie | Article 7 : une vraie remontée d’erreurs |
| Attaques constantes, un certificat expiré en silence | Scans et bruteforce toute la journée, plus un cron de renouvellement au chemin cassé | Article 8 : CrowdSec, et Cloudflare comme étage suivant |
| Bind mounts, aucun backup | Un dossier pgdata effacé, sans volume et sans backup derrière | Article 9 : des backups qui restaurent vraiment |
| Une seule machine, et une demande client | Le client voulait la préprod branchée sur les données de production | Article 10 : quand le client veut les données de prod en préprod |
Où va cette série
Le fil rouge des dix est le même : durcir un SaaS construit en solo sans sortir Kubernetes et sans exploser le budget d’une petite agence. Tout reste sur un VPS que vous pouvez vous offrir. Le but n’est pas une plateforme parfaite. C’est une production sur laquelle vous pouvez dormir.
Je lierai chaque article ici au fur et à mesure des publications, pour que cette page reste la carte où revenir. La suite est celle qui a la meilleure histoire et la leçon la plus nette : le jour où un docker compose build a rempli le disque et fait tomber l’ensemble.