« Ça marche chez moi… »
Ton app Symfony tourne nickel sur ton laptop. PHP 8.3, PostgreSQL 16, les extensions intl et xml installées depuis longtemps, sans même que tu t'en souviennes. Tu la déploies sur le VPS. Et là : PHP 8.2 dans les dépôts Debian, pas de PostgreSQL, et les deux extensions manquantes. Deux heures à rejouer l'installation à la main, dans le mauvais ordre, en lisant des erreurs cryptiques. Puis ça marche. Puis tu refais la manip sur un second serveur, et tu reprends depuis le début.
Le problème n'est pas toi. C'est que l'environnement n'est pas reproductible. Chaque machine a son historique d'installation, sa version de Debian, ses paquets. Ton laptop et ton VPS ne vivent pas dans le même monde. Forcément, ton app se comporte différemment.
Définition : un conteneur Docker, c'est ton app emballée avec son environnement exact : la bonne version de PHP, les extensions, les libs système, la config. Peu importe la machine hôte, le conteneur voit toujours le même monde. Docker Compose va un cran plus loin : il décrit toute ta stack (app + base + workers) dans un seul fichier compose.yml. Une recette de cuisine complète, versionnée dans ton dépôt, reproductible partout.
Pour PHP, il existe même un serveur taillé sur mesure : FrankenPHP, le serveur PHP moderne soutenu par la PHP Foundation depuis mai 2025, qui embarque aussi Caddy (leçon 5). Tu n'as plus besoin de composer un trio nginx + PHP-FPM + Caddy : un seul conteneur gère tout.
Installer Docker, étape par étape
Sur ton VPS tout neuf, Docker n'existe pas encore. On l'installe. Mais pas n'importe comment : depuis le dépôt officiel de Docker.
Pourquoi un dépôt ? apt, c'est le magasin de ta distribution. Chaque apt install va y chercher un paquet. Docker n'est pas dans le rayon de base (ou en version trop vieille). On ajoute donc le rayon officiel de Docker au magasin. Et pour être sûr que personne ne glisse un faux paquet dans ce rayon, on installe d'abord la clé GPG de Docker : la signature qui authentifie chaque paquet.
# 1. Les prérequis (outils de téléchargement et de certificats)
sudo apt update
sudo apt install -y ca-certificates curl
# 2. La clé GPG officielle de Docker (la signature du rayon)
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
# 3. Déclarer le rayon Docker dans le magasin apt
echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
# 4. Installer Docker et le plugin Compose
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
Sur Ubuntu, remplace simplement debian par ubuntu dans les deux URL. C'est tout.
Dernier réglage : par défaut, seul root peut parler à Docker. Le démon (le programme qui gère les conteneurs) tourne en root. On donne le droit à ton utilisateur :
sudo usermod -aG docker deploy
# puis déconnecte-toi et reconnecte-toi :
# les groupes sont lus à l'ouverture de session
Être dans le groupe docker équivaut à être root (on peut monter n'importe quel dossier du système dans un conteneur). Ne l'accorde qu'à ton utilisateur d'administration, deploy. Jamais à l'utilisateur d'une application.
Vérifie que tout est en place :
docker compose version
# Docker Compose version v2.x.x
« Et PostgreSQL, on l'installe quand ? » Jamais, justement. C'est tout l'intérêt de Docker : la base arrive sous forme d'image (postgres:16-alpine) au premier docker compose up. Rien à installer sur l'hôte, rien à configurer dans /etc, et changer de version se fait en changeant un chiffre dans la recette. Sans Docker, l'équivalent serait sudo apt install postgresql, et la version serait celle du magasin.
La recette de cuisine
Voilà à quoi ressemble un compose.yml complet pour une app Symfony servie par FrankenPHP. Chaque ligne a une raison d'être.
services:
php: # ton app Symfony, servie par FrankenPHP
image: ghcr.io/toi/ton-projet:main # l'image construite par ta CI (leçon 6)
restart: unless-stopped
ports:
- "80:80" # FrankenPHP embarque Caddy : c'est lui la porte d'entrée
- "443:443"
- "443:443/udp" # HTTP/3
environment:
SERVER_NAME: ton-projet.fr # Caddy interne : HTTPS automatique (leçon 5)
APP_ENV: prod
APP_SECRET: ${APP_SECRET} # lus depuis le .env à côté du compose.yml
DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app
volumes:
- caddy_data:/data # les certificats HTTPS survivent aux redéploiements
- uploads:/app/public/uploads # les fichiers envoyés par tes utilisateurs aussi
depends_on:
- db
messenger: # tes workers Messenger : la MÊME image, une autre commande
image: ghcr.io/toi/ton-projet:main
restart: unless-stopped
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
environment:
APP_ENV: prod
APP_SECRET: ${APP_SECRET}
DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
caddy_data:
uploads:
pgdata:
restart: unless-stopped : le concierge version Docker. Il redémarre le conteneur après un crash et après un reboot du serveur, sauf si toi-même tu l'as arrêté avec docker compose stop. Sans ça, un conteneur crashé reste mort jusqu'à ton intervention manuelle. C'est le même réflexe que la directive Restart=always de systemd (leçon 3), mais version Docker.
Le service messenger : même image, commande différente. Un conteneur = un processus. Le worker Symfony Messenger ne doit pas tourner à l'intérieur du conteneur php : si le worker plante, Docker le relance indépendamment, sans toucher à ton app web. L'option --time-limit=3600 fait sortir le worker proprement au bout d'une heure, puis Docker le relance frais (sans fuite mémoire possible). La même image garantit que ton worker et ton app web ont exactement le même code.
Note honnête : d'où vient ghcr.io/toi/ton-projet:main ? De ta CI, on en parle à la leçon 6. Pour tester en local d'abord, le template officiel dunglas/symfony-docker te donne un compose de dev prêt à l'emploi.
Le piège des ports exposés. Si tu écris ports: "5432:5432" sur le service db, ta base de données écoute sur toutes les interfaces du serveur, y compris l'interface publique. Elle devient accessible depuis tout internet. Et Docker publie ses ports en contournant ufw : même si ton firewall bloque 5432, Docker l'ouvre quand même via ses règles iptables directes.
La règle : la base de données n'a besoin d'aucun port publié. Les services du même compose.yml se parlent par leur nom de service sur un réseau interne privé (db:5432 dans l'exemple). C'est FrankenPHP/Caddy (leçon 5) qui exposera le HTTPS vers l'extérieur via les ports 80 et 443.
Le volume caddy_data. FrankenPHP embarque Caddy, qui gère le certificat HTTPS de ton domaine. Ce certificat est stocké dans /data à l'intérieur du conteneur. Sans volume, il disparaîtrait à chaque redéploiement, et Caddy demanderait un nouveau certificat à Let's Encrypt à chaque fois. Let's Encrypt impose des limites de taux : trop de demandes et tu es bloqué. Le volume caddy_data fait survivre le certificat entre les mises à jour.
Secrets et volumes. Les mots de passe et variables sensibles vont dans un fichier .env à côté du compose.yml, jamais directement dans le YAML versionné dans ton dépôt. Docker Compose charge ce .env automatiquement. Et pour la persistance des données : toujours utiliser des volumes nommés (pgdata:, uploads: dans l'exemple). Sans volume, les données meurent avec le conteneur à chaque mise à jour.
Et pour une app Node, Python, Go ? Même recette exactement. Tu changes l'image du service applicatif : node:22-alpine, python:3.12-slim, ou l'image de ton binaire Go. La base, les volumes, les secrets, la règle des ports : rien ne change. C'est toute la force de Compose : la logique d'assemblage est universelle.
Dans le compose.yml, la base de données a ports: "5432:5432". À ton avis : qui peut se connecter à ta base, et quelle est la bonne configuration ?
Voir la réponse
Tout internet peut s'y connecter. ports: "5432:5432" publie le port sur 0.0.0.0 : toutes les interfaces, y compris la carte réseau publique du VPS. Pire : Docker gère ses propres règles iptables et contourne ufw pour les ports qu'il publie. La bonne configuration : aucun port publié pour la base. L'app la rejoint via le réseau interne Compose (db:5432). Si un port doit impérativement sortir (pour un outil d'admin par exemple), le préfixer 127.0.0.1: pour le limiter au loopback.
À toi : lance la stack
Ta recette Symfony est dans /opt/app : compose.yml et .env sont prêts. Le terminal simulé ci-dessous réagit comme un vrai VPS. Démarre la stack, vérifie l'état des services, lis les logs.
systemd ou Compose ? Soyons honnêtes
Les deux outils coexistent très bien sur un même VPS. La question est de choisir le bon pour chaque situation :
- Un binaire Go, un script Python, une app sans dépendance externe : systemd suffit (leçon 3). C'est plus simple, plus intégré au système, et tu n'as pas besoin de Docker pour un seul processus.
- Une app + une base de données + des workers : Compose gagne. La parité local/prod, la recette unique versionnée, et la gestion des volumes font toute la différence.
Les deux approches cohabitent très bien. Un service systemd peut parfaitement piloter docker compose : une unit qui exécute docker compose up -d au démarrage et docker compose down à l'arrêt. Tu gardes le monitoring de systemd (journalctl) et la simplicité de gestion des processus, tout en profitant de la reproductibilité de Compose.
compose.yml décrit toute la stack. Laptop ou VPS : les mêmes conteneurs, les mêmes versions, le même comportement."Works on my machine…"
Your Symfony app runs perfectly on your laptop. PHP 8.3, PostgreSQL 16, the intl and xml extensions installed so long ago you've forgotten about them. You deploy it to the VPS. And then: PHP 8.2 from the Debian repos, no PostgreSQL, two missing extensions. Two hours replaying the install by hand, in the wrong order, reading cryptic errors. Then it works. Then you redo the whole thing on a second server and start from scratch again.
The problem isn't you. It's that the environment isn't reproducible. Every machine has its own install history, its version of Debian, its packages. Your laptop and your VPS don't live in the same world — so of course your app behaves differently.
Definition: a Docker container is your app packaged with its exact environment — the right version of PHP, the extensions, the system libs, the config. Whatever the host machine, the container always sees the same world. Docker Compose goes one step further: it describes your entire stack (app + database + workers) in a single compose.yml file. A complete recipe, versioned in your repo, reproducible everywhere.
For PHP, there is even a server built for the job: FrankenPHP, the modern PHP server backed by the PHP Foundation since May 2025, which also bundles Caddy (lesson 5). No more nginx + PHP-FPM + Caddy trio — one container handles everything.
Installing Docker, step by step
On your brand-new VPS, Docker doesn't exist yet. We install it. But not just any way: from Docker's official repository.
Why a repository? apt is your distribution's store. Every apt install fetches a package from it. Docker isn't on the base shelf (or only as an old version). So we add Docker's official shelf to the store. And to make sure nobody slips a fake package onto that shelf, we first install Docker's GPG key: the signature that authenticates every package.
# 1. Prerequisites (download and certificate tools)
sudo apt update
sudo apt install -y ca-certificates curl
# 2. Docker's official GPG key (the shelf's signature)
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
# 3. Declare the Docker shelf in the apt store
echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
# 4. Install Docker and the Compose plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
On Ubuntu, simply replace debian with ubuntu in both URLs. That's it.
One last setting: by default, only root can talk to Docker. The daemon (the program managing containers) runs as root. We grant your user the right:
sudo usermod -aG docker deploy
# then log out and back in:
# groups are read when a session opens
Being in the docker group is equivalent to being root (you can mount any system folder into a container). Grant it only to your admin user, deploy. Never to an application's user.
Check that everything is in place:
docker compose version
# Docker Compose version v2.x.x
"And PostgreSQL, when do we install it?" Never, precisely. That's Docker's whole point: the database arrives as an image (postgres:16-alpine) on the first docker compose up. Nothing to install on the host, nothing to configure in /etc, and changing version means changing one digit in the recipe. Without Docker, the equivalent would be sudo apt install postgresql, with whatever version the store carries.
The recipe
Here's what a complete compose.yml looks like for a Symfony app served by FrankenPHP. Every line is there for a reason.
services:
php: # your Symfony app, served by FrankenPHP
image: ghcr.io/you/your-project:main # the image built by your CI (lesson 6)
restart: unless-stopped
ports:
- "80:80" # FrankenPHP bundles Caddy: it's the entry point
- "443:443"
- "443:443/udp" # HTTP/3
environment:
SERVER_NAME: your-project.com # internal Caddy: automatic HTTPS (lesson 5)
APP_ENV: prod
APP_SECRET: ${APP_SECRET} # read from the .env next to compose.yml
DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app
volumes:
- caddy_data:/data # HTTPS certificates survive redeploys
- uploads:/app/public/uploads # user-uploaded files survive too
depends_on:
- db
messenger: # your Messenger workers: SAME image, different command
image: ghcr.io/you/your-project:main
restart: unless-stopped
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
environment:
APP_ENV: prod
APP_SECRET: ${APP_SECRET}
DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
caddy_data:
uploads:
pgdata:
restart: unless-stopped — the Docker caretaker. It restarts the container after a crash and after a server reboot, except if you yourself stopped it with docker compose stop. Without this, a crashed container stays dead until you intervene manually. Same reflex as systemd's Restart=always (lesson 3), Docker edition.
The messenger service: same image, different command. One container, one process. The Symfony Messenger worker should not run inside the php container: if the worker crashes, Docker restarts it independently without touching your web app. The --time-limit=3600 option makes the worker exit cleanly after an hour, then Docker restarts it fresh (no possible memory leak). The same image guarantees your worker and your web app run exactly the same code.
Honest note: where does ghcr.io/you/your-project:main come from? From your CI — covered in lesson 6. To test locally first, the official dunglas/symfony-docker template gives you a ready-to-use dev compose.
The exposed ports trap. If you write ports: "5432:5432" on the db service, your database listens on all interfaces on the server — including the public network interface. It becomes accessible from the whole internet. And Docker publishes its ports by bypassing ufw: even if your firewall blocks 5432, Docker opens it anyway through its own direct iptables rules.
The rule: the database needs no published port at all. Services in the same compose.yml talk to each other by service name over a private internal network (db:5432 in the example). FrankenPHP/Caddy (lesson 5) exposes HTTPS to the outside world via ports 80 and 443.
The caddy_data volume. FrankenPHP bundles Caddy, which manages your domain's HTTPS certificate. That certificate is stored in /data inside the container. Without a volume, it disappears at every redeploy, and Caddy would request a new certificate from Let's Encrypt each time. Let's Encrypt enforces rate limits: too many requests and you get blocked. The caddy_data volume keeps the certificate alive across updates.
Secrets and volumes. Passwords and sensitive variables go in a .env file next to the compose.yml, never directly in the YAML committed to your repo. Docker Compose loads this .env automatically. And for data persistence: always use named volumes (pgdata:, uploads: in the example). Without a volume, your data is destroyed with the container at every update.
And for a Node, Python, or Go app? Exact same recipe. You just swap the image of the application service: node:22-alpine, python:3.12-slim, or your Go binary's image. The database, volumes, secrets, and port rules stay unchanged. That's the whole power of Compose: the assembly logic is universal.
In the compose.yml, the database has ports: "5432:5432". Your guess: who can connect to your database, and what's the correct configuration?
Show the answer
The whole internet can connect to it. ports: "5432:5432" publishes the port on 0.0.0.0 — all interfaces, including the VPS's public network card. Worse: Docker manages its own iptables rules and bypasses ufw for ports it publishes. The correct configuration: no published port for the database. The app reaches it via the internal Compose network (db:5432). If a port absolutely must be reachable from outside (for an admin tool, say), prefix it with 127.0.0.1: to limit it to loopback.
Your turn: launch the stack
Your Symfony recipe is in /opt/app: compose.yml and .env are ready. The simulated terminal below behaves like a real VPS. Start the stack, check service status, read the logs.
systemd or Compose? Let's be honest
Both tools coexist perfectly on the same VPS. The question is choosing the right one for each situation:
- A Go binary, a Python script, an app with no external dependency: systemd is enough (lesson 3). It's simpler, more integrated with the system, and you don't need Docker for a single process.
- An app + a database + workers: Compose wins. The local/prod parity, the single versioned recipe, and volume management make all the difference.
The two approaches coexist very well. A systemd service can perfectly orchestrate docker compose — a unit that runs docker compose up -d on start and docker compose down on stop. You keep systemd monitoring (journalctl) and simple process management, while benefiting from Compose's reproducibility.
compose.yml describes the whole stack. Laptop or VPS: the same containers, the same versions, the same behaviour.🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Explique avec tes mots pourquoi « ça marche chez moi » disparaît avec Compose, et pourquoi le worker Messenger est un service séparé.
compose.yml est une recette unique versionnée dans Git ; les mêmes images FrankenPHP et Postgres sont utilisées partout ; la base de données est incluse dans la recette ; un worker séparé = supervision indépendante.🧠 Rappel libre
Sans remonter : quelles règles pour les ports dans un compose de prod ?
ufw pour les ports publiés.⚖️ Juge le conseil de l'IA
L'IA te livre un compose.yml avec ports: "5432:5432" sur la base de données et POSTGRES_PASSWORD: password écrit en dur dans le YAML. Elle dit : « C'est plus simple pour commencer, tu changeras ça plus tard. » Tu acceptes, ou tu rejettes ?
ports: "5432:5432" publie la base sur 0.0.0.0, et Docker contourne ufw. N'importe qui peut tenter de se connecter à ta Postgres depuis son terminal. Avec le mot de passe password, c'est une invitation ouverte. 2) Secret versionné : POSTGRES_PASSWORD: password en dur dans le YAML qui part dans ton dépôt Git, c'est un secret exposé à tous les accès présents et futurs au repo. La bonne config : aucun port pour la db, plus un mot de passe fort dans .env non versionné.Ta stack Symfony tourne, tes trois conteneurs sont en vie. FrankenPHP répond déjà sur le port 80. À la leçon 5, on configure Caddy intégré : ton domaine passe en HTTPS automatiquement, le certificat se renouvelle tout seul, et tu n'ouvres jamais un panneau Let's Encrypt.
Leçon 5 : HTTPS automatique avec Caddy →