Masterclass Docker · version 1mis à jour le 3 octobre 2026
Docker, de la boîte à une vraie appli
Ce qu'est vraiment un conteneur, les commandes de tous les jours, le Dockerfile et ce qu'il faut vérifier quand l'IA te l'écrit, Docker Compose. Puis une vraie appli open source que tu lances chez toi : son fichier Compose commenté ligne par ligne, et ce qu'il faut changer avant de la mettre en ligne.
Cinq parties, vingt-deux sections, les pièges et l'antisèche à la fin. Chaque bloc de code se copie d'un clic.
Partie 1 · Comprendre la boîte
Avant la moindre commande, cinq idées. Ce sont celles de la vidéo, en plus précis. Si tu les as, tout le reste de la page se lit tout seul.
Le problème que Docker règle
Tu n'envoies plus ton code, tu envoies la boîte où il marche. Fini le « ça marche chez moi ».
Tout dev l'a vécu. Ton appli marche sur ton ordi. Tu la mets sur le serveur, et elle plante. Pas la même version de Python, une librairie qui manque, un réglage du système qui diffère. Le code est le même, c'est tout ce qu'il y a autour qui a changé.
Docker règle ça en emballant ton appli avec tout ce dont elle a besoin : le bon Python ou le bon Node, les librairies, les fichiers. Cet emballage, c'est un conteneur. La doc de Docker le définit en une phrase : un conteneur, c'est un process isolé, avec tous les fichiers dont il a besoin pour tourner.
La nuance qui compte : ce n'est pas une machine virtuelle. Une VM (virtual machine) fait tourner un système d'exploitation entier, avec son propre noyau — le cœur du système, celui qui parle au matériel. Un conteneur, lui, emprunte le noyau de la machine qui l'héberge. Il n'a rien à démarrer à part ton appli, c'est pour ça qu'il démarre presque tout de suite, et qu'on en fait tourner beaucoup sur une petite machine.
Une machine virtuelle
- Ton appli
- Ses librairies
- Un système complet (avec son noyau)
- L'hyperviseur
- La machine et son système
Chaque VM démarre son propre système. Trois VM = trois systèmes à faire tourner.
Un conteneur
- Ton appli
- Ses librairies
- Docker
- La machine et son système (noyau partagé)
Le conteneur est un process isolé, qui emporte ses fichiers mais emprunte le noyau de la machine. D'où le démarrage quasi immédiat.
Sourcedocs.docker.com, « What is a container? » (conteneur = process isolé, noyau partagé ; VM = système complet avec son noyau). Docker Desktop et sa VM Linux : docs.docker.com, FAQ Docker Desktop pour Mac.
L'image et le conteneur : le moule et les gâteaux
Une image, c'est le moule. Un conteneur, c'est un gâteau sorti du moule. Un moule, et tu en fais autant que tu veux.
C'est la confusion numéro un, alors on la règle tout de suite.
- L'image est un paquet figé : le système de fichiers de ton appli, et la commande à lancer. Elle ne tourne pas. Elle ne change jamais une fois fabriquée.
- Le conteneur est une image qui tourne. Tu peux en lancer dix à partir de la même image : dix conteneurs identiques au départ, chacun sa vie ensuite.
- Le Dockerfile est la recette qui fabrique l'image. Un fichier texte, dans ton projet. On y revient en partie 3.
Pour essayer, installe Docker Desktop (Mac, Windows) ou Docker Engine (Linux), puis lance ton premier conteneur : un serveur web nginx, sorti d'une image toute prête.
Dockerfile
La recette. Un fichier texte.
Image
Le moule. En lecture seule, ne bouge plus.
Conteneurs
web-1web-2web-3
Les gâteaux. Autant que tu veux, du même moule.
Ton premier conteneur
docker run -d -p 8080:80 --name mon-nginx nginx:1.29-alpine
# → ouvre http://localhost:8080 : c'est nginx, qui tourne dans une boîte
docker ps # les conteneurs qui tournent
docker images # les images que tu as sur ta machineDocker n'avait pas l'image ? Il l'a téléchargée tout seul, puis il en a sorti un conteneur. Le -d le lance en arrière-plan, le -p ouvre un port (section 6). Pour tout arrêter : docker stop mon-nginx, puis docker rm mon-nginx. L'image, elle, reste sur ta machine, prête pour le prochain gâteau.
Sourcedocs.docker.com, « What is an image? ». Installer : docs.docker.com, « Get Docker ».
Docker Hub, les tags, et les images légères
Ta recette ne part jamais de zéro : elle part d'une image toute prête. Tout l'art est de choisir laquelle, et de dire précisément quelle version.
Docker Hub, c'est la bibliothèque publique d'images : Python, Node, Postgres, Redis, nginx… Les plus sûres portent le badge Docker Official Image. Quand tu écris nginx, Docker comprend docker.io/library/nginx:latest : le registre (Docker Hub), l'espace des images officielles (library), le nom, et le tag après les deux-points.
Le tag dit quelle version tu veux. Et il y a un piège dans le mot latest : ce n'est pas « la dernière version ». C'est juste le tag que Docker prend quand tu n'en écris aucun. Il pointe sur ce que l'éditeur a décidé, et il bouge sans te prévenir. Ton image de lundi et celle de jeudi peuvent partir de deux bases différentes, avec le même Dockerfile.
Le tag dit aussi quelle variante. Pour la même version de Python, voilà ce qu'affiche Docker Hub :
| L'image | Ce qu'il y a dedans | Taille (compressée, Docker Hub) |
|---|---|---|
python:3.12 | Debian complet, avec les compilateurs et plein d'outils | 411 Mo |
python:3.12-slim | Debian réduit au minimum pour faire tourner Python | 46 Mo |
python:3.12-alpine | Alpine Linux, une distribution minuscule | 21 Mo |
Même écart côté Node : 410 Mo pour node:24, 81 Mo pour node:24-slim, 62 Mo pour node:24-alpine. Une image plus petite se télécharge plus vite, se déploie plus vite, et embarque moins de logiciels où trouver des failles.
Alpine a un prix : elle n'utilise pas la même bibliothèque C que Debian (musl au lieu de glibc). La page officielle de l'image Python le dit elle-même : certains logiciels s'y cassent, selon ce qu'ils attendent du système. Et il n'y a ni bash ni git par défaut.
SourceNom complet et tag par défaut : docs.docker.com, « Build, tag, and publish an image ». Tailles : API de Docker Hub, architecture amd64, relevées le 3 octobre 2026 (elles bougent à chaque mise à jour des images). musl et Alpine : hub.docker.com/_/python, § « Image Variants ».
Jetable : où vont tes données
Un conteneur, tu le supprimes, et tout ce qu'il a écrit dedans disparaît. Ta base avec. Donc les données vivent dehors.
Quand un conteneur écrit un fichier, il l'écrit dans une couche à lui, posée sur l'image. Cette couche meurt avec le conteneur. C'est voulu : un conteneur doit pouvoir être jeté et recréé à tout moment. Une nouvelle version de ton appli, c'est un nouveau conteneur, pas une mise à jour de l'ancien.
Tout ce qui doit survivre sort donc de la boîte. Deux façons :
- Un volume : un espace de stockage géré par Docker, que tu branches dans le conteneur à un chemin précis. Le conteneur meurt, le volume reste. C'est ce qu'on utilise pour une base de données.
- Un bind mount : un dossier de ta machine, branché tel quel dans le conteneur. Pratique en développement, pour voir tes modifs de code sans refabriquer l'image.
Dans le cloud, c'est la même idée à plus grande échelle : la base tourne à côté, dans un service fait pour ça, et les fichiers partent dans un stockage objet. La boîte, elle, reste jetable.
docker volume create pg-data
docker run -d --name ma-base \
-e POSTGRES_PASSWORD=change-moi \
-v pg-data:/var/lib/postgresql/data \
postgres:17-alpine
# Tu peux supprimer ce conteneur et en relancer un autre sur pg-data :
# les données sont toujours là.
docker volume ls # la liste des volumes
docker run -v "$PWD":/app … # bind mount : ton dossier courant, dans /appSourcedocs.docker.com, « Storage » (couche inscriptible du conteneur) et « Volumes ».
La config dehors, les secrets encore plus
La même image en dev et en prod. Ce qui change, ce sont les réglages qu'on lui donne au démarrage.
L'adresse de la base, le mode debug, l'URL du site : rien de ça ne s'écrit dans l'image. Ça se donne au conteneur quand il démarre, par des variables d'environnement. Ton code les lit (os.environ en Python, process.env en Node), et la même image sert partout.
Les secrets (mots de passe, clés d'API) suivent la même règle, en plus strict : ils ne doivent jamais entrer dans l'image. Une image se partage, se pousse sur un registre, se garde des mois. Tout ce qui est dedans, n'importe qui qui a l'image peut le lire. Et un secret écrit dans une ligne ENV du Dockerfile est dans l'image, pour toujours.
docker run -e DEBUG=false -e DATABASE_URL=postgresql://… mon-appli
docker run --env-file .env mon-appli # toutes les variables d'un fichier .env
# Le fichier .env reste sur ta machine ou sur le serveur :
# dans .gitignore ET dans .dockerignore (section 11).Sur un serveur, les valeurs vivent dans l'outil qui déploie (Coolify, un fichier .env protégé, le gestionnaire de secrets de ton cloud), et le fichier Compose ne contient que des ${NOM_DE_LA_VARIABLE}. On le fait pour de vrai en section 20, sur le Compose d'Umami.
Sourcedocs.docker.com, « Environment variables in Compose ». Secrets et image : docs.docker.com, « Build secrets ».
Partie 2 · Les commandes de tous les jours
Une douzaine de commandes font l'essentiel. Lancer, regarder, entrer dedans, arrêter, faire le ménage. Tu les taperas tous les jours.
Lancer un conteneur, et les ports
docker run fait tout : il télécharge l'image s'il faut, crée le conteneur et le démarre. Le port, c'est la porte que tu ouvres entre ta machine et la boîte.
Un conteneur a son propre réseau. Ton appli y écoute sur le port 8000, mais ce 8000-là est dans la boîte : ton navigateur ne le voit pas. -p publie un port, dans cet ordre : port de ta machine : port du conteneur.
docker run -d -p 8080:80 nginx:1.29-alpine
# ^^^^ ^^
# | └ le port où nginx écoute, dans le conteneur
# └ le port que tu ouvres sur ta machine → http://localhost:8080
docker run -d --name web -p 8000:8000 mon-appli # --name : un nom à toi, plutôt qu'un nom au hasard
docker run --rm -it python:3.12-slim python # --rm : supprimé à l'arrêt ; -it : interactifDeux détails qui évitent des heures de recherche :
- Ton appli doit écouter sur
0.0.0.0, pas surlocalhost. Dans le conteneur,localhostveut dire « le conteneur lui-même » : une appli qui n'écoute que là ne reçoit rien de dehors, même avec le bon-p. - Sur un serveur,
-p 5432:5432ouvre ta base à internet. Par défaut, le port est publié sur toutes les adresses de la machine. Et sur Linux, le trafic d'un port publié par Docker passe avant les règles du pare-feuufw: la doc Docker le dit noir sur blanc, ta règle ufw est ignorée. Une base n'a pas besoin de port publié : les autres conteneurs la joignent par le réseau Docker (section 15). Si tu dois vraiment l'ouvrir, limite-la à ta machine :-p 127.0.0.1:5432:5432.
Sourcedocs.docker.com, « Publishing and exposing ports ». Docker et ufw : docs.docker.com, « Packet filtering and firewalls », § « Docker and ufw ».
Voir ce qui tourne, et regarder dedans
Quand ça ne marche pas, trois commandes : ps pour voir s'il tourne, logs pour lire ce qu'il dit, exec pour entrer dedans.
Un conteneur qui « ne marche pas », c'est presque toujours l'un des trois : il s'est arrêté (ps -a te le dit), il a écrit une erreur (logs), ou il tourne mais pas comme tu crois (exec, et tu vas voir).
docker ps # ce qui tourne : nom, image, ports, état
docker ps -a # aussi ce qui s'est arrêté (et son code de sortie)
docker logs web # tout ce que le conteneur a écrit
docker logs -f --tail 100 web # les 100 dernières lignes, puis la suite en direct
docker exec -it web sh # ouvrir un terminal DANS le conteneur
docker exec web env # les variables qu'il a vraiment reçues
docker inspect web # tout son état, en JSON (réseau, volumes, config)
docker stats # CPU et mémoire de chaque conteneur, en directDans un conteneur, prends sh plutôt que bash : les images légères n'ont souvent que sh. Et tout ce que tu modifies à la main là-dedans disparaîtra avec le conteneur (section 4). exec sert à regarder, pas à réparer : la réparation, c'est dans le Dockerfile.
Pour que docker logs serve à quelque chose, ton appli doit écrire ses logs sur la sortie standard, pas dans un fichier. C'est ce que font les images officielles, et c'est ce que Docker attend.
Sourcedocker container logs · docker container exec · docs.docker.com, « Logging ».
Arrêter, supprimer, faire le ménage
Docker garde tout : vieilles images, conteneurs arrêtés, caches de build. Ton disque se remplit en silence. Une commande pour voir, une pour vider.
docker stop envoie au process principal du conteneur un signal pour lui dire « arrête-toi proprement » (SIGTERM). S'il n'est pas parti au bout de 10 secondes, Docker le tue (SIGKILL). Garde ces 10 secondes en tête : on les retrouve en section 13, et elles expliquent pourquoi certains conteneurs mettent toujours 10 secondes à s'arrêter.
docker stop web # arrêter proprement
docker rm web # supprimer le conteneur (l'image reste)
docker rmi mon-appli:1.0 # supprimer une image
docker system df # la place prise par les images, conteneurs, volumes, cache
docker image prune # supprimer les images sans nom (les vieilles versions)
docker system prune # conteneurs arrêtés, réseaux inutilisés, images sans nom, cachedocker system prune ne touche pas aux volumes par défaut : c'est voulu, pour ne pas perdre de données. Il existe une option --volumes. Ne la tape jamais sur un serveur sans savoir exactement quels volumes vont partir.
SourceSignal et délai de docker stop (10 s sous Linux) : docker container stop. Volumes épargnés par défaut : docker system prune.
Partie 3 · Le Dockerfile, et savoir le relire
Aujourd'hui, l'IA t'écrit ton Dockerfile en dix secondes. Ton travail, c'est de savoir le lire : ce que fait chaque ligne, et ce qu'il faut vérifier avant de le mettre en prod.
Un Dockerfile, ligne par ligne
Une recette en quelques lignes : partir d'une image, copier les dépendances, les installer, copier le code, dire quoi lancer.
Voilà le Dockerfile d'une petite appli Python (Flask, servie par gunicorn), qui se connecte à une base Postgres. Je l'ai construit et lancé avant de l'écrire ici : il marche tel quel. Chaque ligne est une instruction, en majuscules, suivie de ses arguments.
Dockerfile
FROM python:3.12-slim
# L'image de départ : Python 3.12, variante légère. Toujours une version précise.
WORKDIR /app
# Le dossier de travail dans l'image. Il est créé s'il n'existe pas.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# D'abord la liste des dépendances seule, et on les installe.
# (Pourquoi avant le code : section 10.)
COPY . .
# Puis tout le code du projet (sauf ce qu'exclut le .dockerignore).
RUN useradd --create-home appuser
USER appuser
# On crée un utilisateur sans droits, et tout ce qui suit tourne avec lui.
EXPOSE 8000
# Documentation : l'appli écoute sur 8000. Ça ne publie rien (c'est le rôle de -p).
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
# La commande lancée au démarrage du conteneur. Forme « exec », entre crochets.Fabriquer l'image, puis la lancer
docker build -t mon-appli:1.0 . # -t : le nom et le tag ; « . » : le dossier du projet
docker run -d -p 8000:8000 -e DATABASE_URL=postgresql://… mon-appli:1.0Les instructions que tu croiseras le plus :
| Instruction | Ce qu'elle fait | Quand |
|---|---|---|
FROM | L'image de départ. | À la fabrication |
WORKDIR | Le dossier courant pour la suite. | À la fabrication |
COPY | Copie des fichiers de ton projet dans l'image. | À la fabrication |
RUN | Lance une commande (installer un paquet, compiler). | À la fabrication |
ENV | Pose une variable d'environnement, gravée dans l'image. | Fabrication et exécution |
ARG | Une variable qui n'existe que pendant la fabrication. | À la fabrication |
USER | L'utilisateur qui lance la suite, et le conteneur. | Fabrication et exécution |
EXPOSE | Documente le port d'écoute. Ne l'ouvre pas. | Documentation |
CMD / ENTRYPOINT | Ce que le conteneur lance au démarrage. | À l'exécution |
HEALTHCHECK | La commande qui dit si le conteneur va bien. | À l'exécution |
La colonne « quand » est celle qui compte. Tout ce qui est RUN et COPY se passe une fois, quand tu fabriques l'image. Au démarrage du conteneur, il ne se passe qu'une chose : CMD. Un pip install ne se relance pas à chaque démarrage, il est déjà dans l'image.
Sourcedocs.docker.com, « Dockerfile reference » (toutes les instructions, dont EXPOSE : « doesn't actually publish the port »).
Les couches, et l'ordre du cache
Chaque ligne fabrique une couche. Docker réutilise celles qui n'ont pas bougé — jusqu'à la première qui change. Tout le reste se refait.
Une image n'est pas un bloc : c'est une pile de couches. RUN, COPY et ADD en ajoutent chacune une. Quand tu relances docker build, Docker regarde chaque ligne : rien n'a changé ? Il reprend la couche en cache, instantanément. Mais la doc est claire : dès qu'une couche change, toutes celles qui viennent après se refont, même si elles n'ont rien à voir.
D'où l'ordre du Dockerfile de la section 9. J'ai modifié app.py et relancé le build : les couches jusqu'au pip install sont sorties en CACHED, seule la copie du code s'est refaite. Si le Dockerfile commençait par COPY . ., chaque virgule changée dans le code relancerait l'installation de toutes les dépendances.
| La ligne (une couche) | Tu changes app.py | Tu ajoutes une librairie |
|---|---|---|
FROM python:3.12-slim | en cache | en cache |
WORKDIR /app | en cache | en cache |
COPY requirements.txt . | en cache | refait |
RUN pip install … | en cache | refait |
COPY . . | refait | refait |
RUN useradd … / USER / CMD | refait | refait |
docker build -t mon-appli:1.1 . # observe les lignes CACHED
docker build --no-cache -t mon-appli . # tout refaire, sans cache (quand tu doutes)
docker history mon-appli:1.1 # les couches de l'image, et leur tailleDeux conséquences qu'on oublie :
- Une couche ne s'efface pas. Un fichier copié dans une couche puis supprimé dans la suivante est toujours dans l'image, dans la couche d'avant. C'est pour ça que le nettoyage se fait dans le même
RUNque l'installation :apt-get update && apt-get install … && rm -rf /var/lib/apt/lists/*. apt-get updateseul sur sa ligne est un piège : sa couche reste en cache, et unapt-get installajouté plus tard part d'une liste de paquets périmée. La doc demande de les mettre dans le mêmeRUN.
Sourcedocs.docker.com, « Docker build cache » (« If a layer changes, all other layers that come after it are also affected »). apt-get : docs.docker.com, « Best practices », § RUN.
Le .dockerignore
COPY . . copie tout. Vraiment tout : ton .env, ton .git, tes node_modules. Sauf ce que tu listes dans .dockerignore.
Au moment du docker build, Docker envoie le dossier du projet au moteur de build : c'est le contexte. Le fichier .dockerignore, à la racine, dit ce qui n'en fait pas partie. Même syntaxe qu'un .gitignore.
.dockerignore
.git
.env
.env.*
node_modules
dist
__pycache__/
*.pyc
.venv/Trois raisons, par ordre d'importance :
- Les secrets. Sans lui, ton
.envpart dans l'image au premierCOPY . .. - Le cache. Sans lui, toucher n'importe quel fichier (un log, un fichier du
.git) change la coucheCOPY . .et relance tout ce qui suit. - La taille et la justesse. Tes
node_modulesont été installés pour ton Mac ; l'image doit installer les siens, pour Linux.
Sourcedocs.docker.com, « Build context », § .dockerignore files.
Le multi-stage : construire dans une image, livrer dans une autre
Les outils qui servent à construire n'ont rien à faire en prod. Un étage pour construire, un étage pour tourner, et seul le dernier part.
Pour construire un site React ou Vue, il te faut Node, npm, et des centaines de Mo de dépendances. Pour le servir, il te faut un dossier de fichiers statiques et un serveur web. Le multi-stage met les deux dans le même Dockerfile, avec plusieurs FROM. Chaque FROM ouvre un étage ; le dernier devient l'image. COPY --from va chercher un fichier dans un étage précédent.
Dockerfile d'un site statique (testé)
# Étage 1 : on construit
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Étage 2 : on sert. Seul cet étage devient l'image.
FROM nginx:1.29-alpine
COPY --from=build /app/dist /usr/share/nginx/htmlNode, npm et les node_modules restent dans l'étage 1, qui est jeté. L'image finale, c'est nginx et ton dossier dist. Rien d'autre.
Une de mes applis en prod est construite comme ça, en trois étages : un étage de base avec ce qu'il lui faut pour tourner, un étage de construction qui ajoute les compilateurs et fait le build, puis l'image finale, qui repart de l'étage de base et ne récupère que le résultat. Et comme en section 10, les dépendances sont téléchargées à partir du seul fichier de verrouillage (lockfile) avant de copier le code : une modif de code ne retélécharge rien.
Cette image, je ne la construis jamais sur le serveur. Le build demande à lui seul environ 4 Go de mémoire, et le serveur en a 4, déjà pris par la prod. C'est la CI (GitHub Actions) qui fabrique l'image à chaque version et la pousse sur un registre. Le serveur ne fait que la tirer et la lancer. Tu feras pareil en partie 5 : tu tireras une image qu'une autre équipe a construite.
Relire le Dockerfile que l'IA t'écrit
Un Dockerfile qui marche n'est pas un Dockerfile prêt pour la prod. Voilà ce que je vérifie, ligne par ligne, avant d'en accepter un.
Demande un Dockerfile à n'importe quel assistant, tu en auras un qui build. C'est justement le problème : les pièges d'un Dockerfile ne font pas d'erreur. L'image se construit, le conteneur démarre, et le souci ne se voit que plus tard — un secret qui fuit, un build qui refait tout à chaque modif, un arrêt qui traîne. Ce ne sont pas des défauts « de l'IA » : ce sont les pièges classiques, documentés par Docker, que n'importe quel Dockerfile peut contenir. Ta relecture passe sur chacun.
| Ce que tu vérifies | Pourquoi | Ce que tu veux voir |
|---|---|---|
1. Le FROM a une version précise | latest ou pas de tag : la base change sans prévenir (section 3). | python:3.12-slim, node:24-slim |
| 2. Une variante légère | L'image complète pèse jusqu'à neuf fois plus que la -slim (section 3), pour des outils qui ne servent qu'à construire. | -slim, ou -alpine si tu as testé |
| 3. Les dépendances copiées avant le code | Sinon chaque modif de code réinstalle tout (section 10). | COPY requirements.txt ou package*.json, RUN install, puis COPY . . |
4. Un .dockerignore existe | Sans lui, COPY . . embarque .env et .git (section 11). | Le fichier, avec au moins .env et .git |
| 5. Aucun secret dans l'image | Une valeur dans ENV reste dans l'image. Un ARG se lit dans docker history. Un COPY .env copie le fichier. | Les secrets donnés au démarrage (-e, Compose). Au build : RUN --mount=type=secret |
6. apt-get update et install dans le même RUN | Séparés, le cache garde une liste de paquets périmée. Et le ménage doit être dans la même couche. | apt-get update && apt-get install -y --no-install-recommends … && rm -rf /var/lib/apt/lists/* |
7. Un USER qui n'est pas root | Sans USER, le conteneur tourne avec l'utilisateur de l'image de base. J'ai vérifié sur python:3.12-slim et node:24-alpine : c'est root dans les deux cas. | USER appuser (ou USER node : l'image Node fournit cet utilisateur) |
8. Le CMD entre crochets | En forme « shell » (CMD python app.py), Docker lance /bin/sh -c, qui ne transmet pas les signaux à ton appli. | CMD ["gunicorn", "…"] |
| 9. L'appli s'arrête quand on lui demande | Voir le test juste en dessous. | Un serveur qui gère l'arrêt, ou --init / init: true |
| 10. Un seul rôle par conteneur | La doc : chaque conteneur ne devrait avoir qu'une responsabilité. La base dans l'image de l'appli, c'est non. | L'appli seule. La base, Redis : leurs propres conteneurs, via Compose |
11. EXPOSE n'est pas une ouverture | Il documente, il ne publie rien. Si l'IA te dit que EXPOSE ouvre le port, c'est faux. | Le port ouvert par -p ou ports: |
| 12. Un multi-stage s'il y a une compilation | Sinon les compilateurs et les dépendances de build partent en prod (section 12). | Plusieurs FROM, un COPY --from |
Le test des points 8 et 9. J'ai lancé la même petite appli de quatre façons, puis mesuré le temps de docker stop :
| Comment l'appli est lancée | docker stop a pris |
|---|---|
CMD python app.py (forme shell : c'est sh qui reçoit le signal) | 10,2 s |
CMD ["python", "app.py"] (forme exec, mais le serveur de dev de Flask) | 10,2 s |
La même chose, avec docker run --init | 0,1 s |
CMD ["gunicorn", …] (forme exec, serveur qui gère l'arrêt) | 0,2 s |
10 secondes, c'est le délai de docker stop (section 8) : l'appli n'a jamais entendu le signal, Docker a fini par la tuer. Dans le deuxième cas, la forme exec ne suffit pas : le process qui tourne en premier dans un conteneur (le « PID 1 ») n'a pas de réaction par défaut aux signaux, il faut que le programme les gère lui-même. Un process tué d'office, c'est une requête coupée en plein milieu et un déploiement qui prend 10 secondes de trop. --init (ou init: true dans Compose) glisse un mini-process devant ton appli, qui fait suivre les signaux.
SourceToute la liste suit docs.docker.com, « Building best practices » (versions, apt-get, USER, une responsabilité par conteneur, CMD en forme exec). Signaux et forme shell : Dockerfile reference, § ENTRYPOINT. Secrets : « Build secrets » (« Build arguments and environment variables are inappropriate for passing secrets »). --init : docker container run. docker init : docs.docker.com. Utilisateur par défaut et temps d'arrêt : mesurés par moi, Docker Engine 28.1, le 29 septembre 2026.
Partie 4 · Docker Compose
Une vraie appli, ce n'est jamais une seule boîte : l'appli, la base, le cache. Compose les décrit toutes dans un seul fichier, et les lance d'un coup.
Plusieurs boîtes, un seul fichier
Un fichier compose.yaml, une commande, et toute ton appli démarre : ses conteneurs, son réseau, ses volumes.
Sans Compose, pour lancer l'appli de la section 9 avec sa base, il faut créer un réseau, un volume, et taper deux docker run de trois lignes chacun, dans le bon ordre. Avec Compose, tu décris le résultat voulu dans un fichier, et docker compose up s'occupe du reste. Le fichier s'appelle compose.yaml : c'est le nom préféré par Docker (docker-compose.yaml marche encore, pour la compatibilité). La ligne version: qu'on voit en tête des vieux tutos ne sert plus à rien : Compose affiche un avertissement et l'ignore.
Voilà le fichier de l'appli Python + Postgres, commenté. Je l'ai lancé tel quel : l'appli répond et lit la version de Postgres dans la base.
compose.yaml
services:
web: # service 1 : ton appli
build: . # l'image se fabrique avec le Dockerfile du dossier
ports:
- "8000:8000" # port de ta machine : port du conteneur
environment:
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
# ^ « db » = le nom du service d'en dessous
depends_on:
db:
condition: service_healthy # attendre que la base soit PRÊTE, pas juste démarrée
restart: unless-stopped # relancer si ça plante, sauf si tu l'as arrêté toi-même
db: # service 2 : la base
image: postgres:17-alpine # une image toute prête, version fixée
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # lu dans le fichier .env, à côté
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data # les données, dehors
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"] # la commande qui dit « je suis prête »
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# pas de « ports: » : la base n'est joignable que par les autres conteneurs
volumes:
db-data: # le volume nommé, créé au premier « up »
.env, à côté (jamais dans Git)
POSTGRES_PASSWORD=change-moiTu vois tout ce qu'on a vu jusqu'ici : l'image fabriquée par le Dockerfile (build), une image de Docker Hub à version fixe, un port publié, la config dehors, le secret dans une variable, les données dans un volume. ${POSTGRES_PASSWORD} est remplacé par la valeur du fichier .env posé à côté du compose.yaml : Compose le lit tout seul.
SourceNom du fichier : docs.docker.com, « How Compose works ». version obsolète : « Version and name top-level elements ». Variables : « Interpolation ».
Le réseau : on s'appelle par son nom
Dans Compose, l'adresse d'un service, c'est son nom. Ton appli joint la base à db:5432. Pas d'IP, jamais.
Au up, Compose crée un réseau pour le projet (<nom-du-projet>_default) et y branche tous les services. Chaque service y est enregistré sous son nom : un conteneur joint l'autre en l'appelant par son nom, comme une adresse. Dans l'exemple, db dans DATABASE_URL, c'est le service db.
La doc insiste sur un point qui piège tout le monde : entre conteneurs, on utilise le port du conteneur. Le port de ta machine (la partie gauche de ports:) ne sert qu'à entrer depuis l'extérieur. La base écoute sur 5432 dans son conteneur, l'appli joint db:5432, et la base n'a besoin d'aucun ports:.
| Dans le fichier | Qui peut joindre le port |
|---|---|
| Rien | Les autres conteneurs du même réseau, par le nom du service. C'est le cas le plus courant. |
expose: ["5000"] | Pareil : les conteneurs du réseau. Documente le port, n'ouvre rien vers l'extérieur. |
ports: ["8000:8000"] | Aussi ta machine, et tout le réseau de ta machine. Sur un serveur, attention (section 6). |
Tu peux aussi déclarer tes propres réseaux, pour isoler des groupes de services, ou brancher un fichier Compose sur un réseau créé ailleurs (external: true). Je m'en sers en prod pour séparer l'infra de l'appli : c'est l'histoire de la section 16.
Sourcedocs.docker.com, « Networking in Compose » (nom du service, HOST_PORT / CONTAINER_PORT, réseaux externes).
depends_on, healthcheck, restart : l'ordre de démarrage
depends_on attend que la base soit démarrée. Pas qu'elle soit prête. La différence, c'est le healthcheck.
Postgres démarre en une seconde, mais met un peu plus longtemps à accepter des connexions. Si ton appli se lance entre les deux, elle plante. depends_on ne suffit pas tout seul : la doc de Compose le dit, par défaut il attend que le conteneur tourne, pas qu'il soit prêt. Il existe trois conditions :
| La condition | Compose attend que… |
|---|---|
service_started (défaut) | le conteneur soit lancé. C'est tout. |
service_healthy | son healthcheck passe. Il faut donc un healthcheck. |
service_completed_successfully | le conteneur ait fini son travail et se soit arrêté sans erreur. Pour une tâche qui prépare quelque chose avant les autres. |
Le healthcheck, c'est une commande que Docker lance régulièrement dans le conteneur. Elle réussit : le conteneur est healthy. Elle rate plusieurs fois de suite : unhealthy. Pour Postgres, pg_isready ; pour Redis, redis-cli ping. Ses réglages :
interval: le temps entre deux vérifications (30 s par défaut) ;timeout: au-delà, la vérification compte comme ratée (30 s par défaut) ;retries: le nombre d'échecs de suite avantunhealthy(3 par défaut) ;start_period: un temps de grâce au démarrage, où les échecs ne comptent pas (0 par défaut).
Et la limite qu'on découvre en prod : depends_on ne vaut que dans un même projet Compose. Il ne voit que les services du même fichier (ou des fichiers fusionnés avec -f). Il ne sait rien d'une base lancée ailleurs. Et il ne joue qu'au démarrage : si la base redémarre plus tard, ton appli doit savoir se reconnecter toute seule.
Dernier réglage, restart : ce que Docker fait quand le conteneur s'arrête. no (défaut) : rien. always : il le relance, y compris au redémarrage de Docker. unless-stopped : pareil, sauf si c'est toi qui l'as arrêté. on-failure : seulement s'il s'est arrêté sur une erreur.
Ce que ça a donné chez moi. Une de mes applis en prod : sept services, un seul serveur. Au départ, tout vivait dans un seul fichier Compose, et mon outil de déploiement recréait les sept conteneurs à chaque mise en ligne, base comprise. Un des services met deux minutes à démarrer, et l'appli l'attendait. Résultat : trois à quatre minutes de coupure à chaque déploiement. Ce chiffre-là est une estimation, je ne l'avais pas chronométré.
J'ai coupé en deux fichiers. L'infra d'un côté (la base, le cache, le service lent), déployée une fois, puis on n'y touche plus. L'appli de l'autre, la seule qu'on redéploie. Les deux se retrouvent sur un réseau partagé, déclaré external: true côté appli (section 15). Coupure mesurée après la bascule : 34 secondes. Même serveur, zéro euro de plus.
Le prix : plus de depends_on entre l'appli et sa base, puisqu'elles ne sont plus dans le même fichier. Au redémarrage du serveur, tout remonte en même temps, sans ordre. Alors l'appli attend elle-même. Un petit script, lancé en tout premier dans son conteneur, ne rend la main que quand ses dépendances répondent :
Le script d'attente (extrait simplifié)
// Attend que la base, le cache et le service lent soient PRÊTS, puis rend la main.
// C'est la première chose que lance l'appli au démarrage, avant tout le reste.
const deps = [
['base', () => tcp(hostPort(process.env.DATABASE_URL, 5432))], // le port répond
['cache', () => tcp(hostPort(process.env.REDIS_URL, 6379))],
['lent', serviceLentPret], // pas juste le port : ce service écoute bien avant de savoir servir,
// alors on lui pose une vraie question
];
for (const [name, check] of deps) {
for (;;) {
try {
await check();
log(`${name} ready`);
break;
} catch (e) {
if (Date.now() - started > TIMEOUT_S * 1000) {
process.exit(1); // on abandonne ; « restart: always » relancera le conteneur
}
log(`waiting for ${name}`);
await new Promise((r) => setTimeout(r, 5000)); // on réessaie toutes les 5 s
}
}
}
log('all dependencies ready'); // la ligne à chercher dans les logs après un reboot
Pour le service lent, un port qui répond ne suffit pas : il écoute bien avant de savoir servir. Le script lui pose donc une vraie question, celle que l'appli lui posera en premier. Et au bout de dix minutes sans réponse, il abandonne avec une erreur : le conteneur s'arrête, et restart: always le relance pour un nouvel essai.
Je l'ai testé en redémarrant le serveur. L'appli est arrivée avant le service lent, le script a attendu 57 secondes, puis tout a démarré seul.
Sourcedocs.docker.com, « Control startup order » (« Compose does not wait until a container is "ready", only until it's running »). Valeurs par défaut du healthcheck : Dockerfile reference, § HEALTHCHECK. « Start containers automatically » (politiques de redémarrage). Coupure au déploiement et attente au redémarrage : mesurées sur mon serveur le 22 septembre 2026.
Les commandes Compose
Les mêmes réflexes qu'avec docker, préfixés de compose, sur toute l'appli d'un coup. Et une option à ne jamais taper par réflexe : -v.
Toutes ces commandes se lancent dans le dossier du compose.yaml, et parlent des services par leur nom (web, db), pas des conteneurs.
docker compose up -d # tout lancer, en arrière-plan
docker compose up -d --build # refabriquer les images d'abord (après une modif de code)
docker compose ps # l'état de chaque service (et healthy / unhealthy)
docker compose logs -f web # les logs d'un service, en direct
docker compose exec web sh # un terminal dans le service web
docker compose restart web # redémarrer un service
docker compose config # le fichier final, variables remplacées : pour vérifier
docker compose down # tout arrêter et supprimer conteneurs et réseau
docker compose down -v # … ET les volumes : la base part avecJ'ai vérifié la différence : après un down, le volume de la base est toujours là, le prochain up le rebranchera. Après un down -v, le volume a disparu, et les données avec. En local, c'est pratique pour repartir de zéro. Sur un serveur, c'est ta base de prod.
docker compose config est la commande sous-cotée de la liste : elle affiche le fichier tel que Compose le comprend, avec les variables remplacées. Une variable vide, une indentation cassée : tu le vois avant de lancer quoi que ce soit.
Sourcedocker compose down (« -v, --volumes: Remove named volumes declared in the "volumes" section of the Compose file »). Référence de docker compose.
Partie 5 · Ta première vraie appli : Umami
Tout ce qui précède, sur une vraie appli open source que tu lances chez toi : Umami, un outil d'analytics pour ton site. Deux services, un fichier Compose de moins de quarante lignes, écrit par l'équipe du projet. Tu sais déjà le lire.
Umami, lancée chez toi en trois commandes
Un outil d'analytics open source, qui tourne sur ta machine en trois commandes. Tu repars avec quelque chose d'utile, et tu sais ce qu'il y a dedans.
Umami compte les visites d'un site : combien de monde, d'où ils viennent, quelles pages ils lisent. C'est un logiciel libre (licence MIT), que tu installes sur ta propre machine : tes chiffres sont dans ta base, pas chez quelqu'un d'autre. Moi, j'utilise Umami et PostHog. Umami, je l'héberge moi-même, sur un de mes serveurs.
Je l'ai pris pour cette page parce que son dépôt GitHub contient un fichier Compose officiel, court, qui se sert d'à peu près tout ce que tu viens d'apprendre. Il n'y a rien à fabriquer : le fichier suffit.
mkdir umami && cd umami
curl -O https://raw.githubusercontent.com/umami-software/umami/master/docker-compose.yml
docker compose up -d
# → ouvre http://localhost:3000
# identifiant : admin · mot de passe : umami (le compte créé par défaut)Ce que ça donne, une fois lancé :
- Deux images téléchargées. Dans
docker images, celle d'Umami pèse 1,05 Go, celle de Postgres 286 Mo. - Au premier démarrage, Compose attend que la base se déclare prête, puis Umami crée ses tables (26 migrations dans les logs) et démarre.
docker compose down, puisup: le site que j'avais déclaré dans Umami est toujours là.down -v, puisup: plus rien, retour au compteadmin/umami. Le volume, comme en section 17.
Si Compose te répond address already in use, c'est que le port 3000 est déjà pris sur ta machine (c'est le port par défaut de beaucoup d'outils de dev). Ça m'est arrivé pendant ce test. Libère le port, ou change le chiffre de gauche dans ports: (section 6).
Sourcegithub.com/umami-software/umami (licence MIT, Umami 3.4.0). Compte par défaut : umami.is/docs, « Login ». Tailles relevées sur mon Mac (arm64), Docker Engine 28.1, le 3 octobre 2026.
Le Compose officiel, ligne par ligne
Un fichier écrit par l'équipe d'Umami, pas par moi. Tu vas y reconnaître presque chaque section de cette page.
Voilà le fichier que tu viens de lancer. Je n'ai touché à aucune ligne de YAML : j'ai seulement ajouté les commentaires. D'abord ce qu'il construit, ensuite le fichier.
Ton navigateurouvre le tableau de bord
Le navigateur d'un visiteur de ton sitecharge script.js, puis envoie sa visite
Le réseau du projet · umami_default
umami
L'appli. Image tirée de GHCR, port 3000 publié.
db
Postgres. Aucun port publié : invisible de l'extérieur.
Le volume umami_umami-db-data · hors des boîtes, il survit à down, pas à down -v
docker-compose.yml (le fichier du projet, mes commentaires)
---
# docker-compose.yml du repo umami-software/umami (licence MIT), Umami 3.4.0.
# Le YAML est celui du projet, tel quel. Les commentaires sont de moi.
services:
umami: # service 1 : l'appli
image: ghcr.io/umami-software/umami:latest # image toute faite, tirée de GHCR. Pas de « build »
ports:
- "3000:3000" # ta machine : le conteneur
environment:
DATABASE_URL: postgresql://umami:umami@db:5432/umami
# ^ user ^ mot de passe ^ « db » = le service d'en dessous
# Protège les jetons de connexion. Une valeur d'exemple, à remplacer
APP_SECRET: replace-me-with-a-random-string
# Nécessaire pour la double authentification. Se génère avec : openssl rand -hex 32
TWO_FACTOR_ENCRYPTION_KEY: replace-me-with-a-64-character-hex-string
depends_on:
db:
condition: service_healthy # attendre que la base soit PRÊTE
init: true # fait suivre les signaux d'arrêt
restart: always # relancée si elle plante
healthcheck:
test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
# ici, localhost = le conteneur lui-même
interval: 5s
timeout: 5s
retries: 5
db: # service 2 : la base
image: postgres:15-alpine # version fixée, variante légère
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: umami # en dur, comme dans DATABASE_URL
volumes:
- umami-db-data:/var/lib/postgresql/data # les données, dehors
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
# $$ : un vrai « $ », laissé au conteneur. Avec un seul,
# c'est Compose qui remplacerait la variable
interval: 5s
timeout: 5s
retries: 5
# pas de « ports: » : la base n'est joignable que par l'appli
volumes:
umami-db-data: # le volume nommé
Ce que chaque bloc reprend de la page :
| Dans le fichier | Ce que c'est | À relire |
|---|---|---|
image: (les deux) | Deux images toutes prêtes, tirées de deux registres : GHCR et Docker Hub. | Section 3, les images et les tags |
ports: | La porte de l'appli. La base n'en a pas. | Section 6, les ports |
environment: | La config et les secrets, donnés au démarrage. | Section 5, la config dehors |
@db:5432 | L'appli joint la base par le nom du service. | Section 15, le réseau |
volumes: | Les données de la base, hors du conteneur. | Section 4, les volumes |
healthcheck: et depends_on: | L'appli ne démarre qu'une fois la base prête. | Section 16, l'ordre de démarrage |
init: true | Les signaux d'arrêt arrivent jusqu'à l'appli. | Section 13, le test du docker stop |
Trois choses que la démo de la partie 4 n'avait pas :
- Pas de
build:. L'image est construite par l'équipe d'Umami et publiée sur un registre, comme dans mon histoire de la section 12. Leur Dockerfile est dans le dépôt, et tu sais le lire : plusieurs étages, un utilisateur qui n'est pas root, unCMDentre crochets. J'ai vérifié dans le conteneur :whoamirépondnextjs, pasroot. - Un healthcheck sur l'appli aussi, pas seulement sur la base.
docker compose psaffichehealthypour les deux. init: true, le réglage de la section 13. Le premier process du conteneur est bien le mini-process de Docker.
Et deux choses que cette page t'a appris à ne pas laisser passer : le tag latest, et des mots de passe écrits en dur. Pour essayer chez toi, ça ne gêne personne. Pour le mettre en ligne, si.
SourceLe fichier : docker-compose.yml du dépôt umami-software/umami, lu le 3 octobre 2026. Son Dockerfile. Variables : umami.is/docs, « Environment variables ».
Quatre choses à changer avant de l'exposer sur internet
Chez toi, les valeurs d'exemple ne gênent personne. Sur un serveur, n'importe qui peut ouvrir la page de connexion et taper admin, puis umami.
Umami démarre très bien avec ses valeurs d'exemple. Rien ne t'oblige à les changer, et c'est bien le problème. Avant de le poser sur un serveur :
- Le tag.
latestbouge sans prévenir (section 3). Aujourd'hui,latestet3.4.0sont la même image. Écris3.4.0: c'est toi qui choisis le jour de la mise à jour. - Les secrets.
APP_SECRETprotège les jetons de connexion : la doc d'Umami demande une valeur unique par installation. Pareil pour la clé de la double authentification. - Les mots de passe. Celui de la base (
umami, écrit deux fois dans le fichier) part dans le.env. Celui du compteadminse change dans l'interface, dès la première connexion. - Le port.
"3000:3000"ouvre l'appli à tout internet, en HTTP, et sans passer par ton pare-feu (section 6). Avec127.0.0.1devant, elle n'est joignable que depuis le serveur lui-même. Devant, tu mets un reverse proxy (Caddy, nginx, ou celui de ton outil de déploiement) : c'est lui qui reçoit le trafic en HTTPS et le passe à l'appli.
compose.yaml, la version à mettre en ligne
services:
umami:
image: ghcr.io/umami-software/umami:3.4.0 # 1. une version précise
ports:
- "127.0.0.1:3000:3000" # 4. le serveur seulement
environment:
# 3. plus de mot de passe en dur
DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami
APP_SECRET: ${APP_SECRET} # 2. lus dans le .env
TWO_FACTOR_ENCRYPTION_KEY: ${TWO_FACTOR_ENCRYPTION_KEY}
depends_on:
db:
condition: service_healthy
init: true
restart: always
healthcheck:
test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
interval: 5s
timeout: 5s
retries: 5
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # 3. la même variable
volumes:
- umami-db-data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
umami-db-data:
Le .env, à côté (jamais dans Git)
# Trois valeurs tirées au hasard. -hex : que des chiffres et des lettres,
# donc rien qui casse l'adresse de DATABASE_URL.
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env
echo "APP_SECRET=$(openssl rand -hex 32)" >> .env
echo "TWO_FACTOR_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
docker compose config -q && docker compose up -d # config d'abord : une variable vide se voit iciJe l'ai lancé tel quel : l'appli répond sur localhost:3000, et plus du tout depuis l'adresse de mon Mac sur le réseau.
Le piège, si tu as déjà lancé le fichier d'origine. Postgres écrit son mot de passe dans le volume au tout premier démarrage. Changer la variable ensuite ne change rien : la base garde l'ancien. J'ai essayé. La base s'affiche healthy, et Umami redémarre en boucle sur password authentication failed. Sur une installation de test, docker compose down -v, et tu repars de zéro. Sur une base qui a déjà des données, le mot de passe se change dans Postgres, pas dans le fichier.
SourceAPP_SECRET : umami.is/docs, « Environment variables ». Compte par défaut et changement de mot de passe : umami.is/docs, « Login ». Variables de l'image Postgres, lues seulement sur un dossier de données vide : hub.docker.com/_/postgres. Ports publiés et pare-feu : docs.docker.com, « Packet filtering and firewalls ».
Le brancher sur ton site
Un site à déclarer, une ligne de script à coller. Et une condition : le navigateur de tes visiteurs doit pouvoir joindre Umami.
- Dans Umami, ouvre Websites, puis Add website. Donne un nom, et le domaine de ton site.
- Clique sur Edit en face de ce site. Sous Tracking code, Umami te donne une ligne de script, avec l'identifiant du site dedans.
- Colle cette ligne dans le
<head>de tes pages.
La ligne à coller (celle de mon test)
<script defer
src="http://localhost:3000/script.js"
data-website-id="l-identifiant-de-ton-site"></script>Je l'ai fait sur une page de test, ouverte dans Chrome : une visite, et Umami affiche 1 visiteur, 1 page vue. Il faut un vrai navigateur : c'est lui qui exécute le script, et Umami écarte les robots par défaut.
Regarde le dessin de la section 19 : ce n'est pas ton site qui envoie la visite, c'est le navigateur de ton visiteur. Tant qu'Umami tourne sur ton ordi, localhost:3000 ne veut rien dire pour lui : tu ne compteras que tes propres visites. Pour un vrai site, il te faut :
- Umami sur un serveur allumé en permanence, avec le fichier de la section 20. Pour choisir le serveur : les cinq types de serveurs, et les moins chers ;
- un nom de domaine qui pointe dessus, par exemple
stats.ton-site.fr; - du HTTPS devant. Si ton site est en HTTPS, le navigateur refuse de charger un script en HTTP.
La ligne devient alors src="https://stats.ton-site.fr/script.js", et rien d'autre ne change. Dernier détail : certains bloqueurs de pub bloquent ce script. Ton compteur sera toujours un peu en dessous de la réalité.
Sourceumami.is/docs, « Add a website » et « Collect data » (dont les bloqueurs de pub). Robots écartés par défaut : « Environment variables », DISABLE_BOT_CHECK. Script HTTP sur une page HTTPS : MDN, « Contenu mixte ».
Et Kubernetes ?
C'est quand tu as des centaines de boîtes sur plein de machines. Pour commencer, tu n'en as pas besoin.
Compose gère des conteneurs sur une machine. Kubernetes gère des conteneurs sur un groupe de machines : il décide où chaque conteneur tourne, en relance ailleurs quand une machine tombe, en ajoute quand la charge monte, et fait les mises à jour sans coupure. C'est puissant, et c'est un métier à part entière.
Une de mes applis en prod, sept services, tient sur un seul serveur et deux fichiers Compose. La plupart des projets perso, et beaucoup de projets pros, n'ont pas besoin de plus. Et tout ce que tu as appris ici se retrouve dans Kubernetes : les images, les tags, les variables d'environnement, les volumes, les healthchecks, les services qu'on appelle par leur nom. Apprends Docker et Compose à fond d'abord. Le jour où Kubernetes arrive dans ton équipe, tu n'auras que la couche du dessus à apprendre.
Les 8 pièges
Ce que tout le monde fait au moins une fois, et ce qu'il faut faire à la place.
- 1
« J'ai mis FROM python, ça marche ». Sans tag, c'est latest, et latest bouge sans prévenir. Écris la version et la variante : python:3.12-slim.
- 2
« J'ai mis le mot de passe dans le Dockerfile, c'est plus simple ». Il est dans l'image, pour toujours, et chez tous ceux qui l'ont. Les secrets se donnent au démarrage, par des variables.
- 3
« J'ai modifié le fichier dans le conteneur avec exec ». Il disparaîtra avec le conteneur. On répare dans le Dockerfile ou dans la config, puis on recrée.
- 4
« Ma base tourne dans un conteneur, sans volume ». Premier docker compose down, plus de base. Un volume nommé pour chaque dossier de données.
- 5
« J'ai publié le port de la base, pour y accéder depuis chez moi ». Sur un serveur, -p 5432:5432 l'ouvre à internet, et ufw ne l'arrête pas. Pas de ports: pour une base, ou 127.0.0.1 devant.
- 6
« Mon appli écoute sur localhost, et le -p ne marche pas ». Dans un conteneur, localhost, c'est le conteneur. Écoute sur 0.0.0.0.
- 7
« depends_on, donc la base est prête ». Démarrée, pas prête. Un healthcheck et condition: service_healthy, et une appli qui sait réessayer.
- 8
« docker compose down -v, pour faire propre ». Le -v supprime les volumes : la base part avec. En local, pourquoi pas. Sur un serveur, jamais par réflexe.
L'antisèche
Toute la page en une vue, rangée par situation. À garder ouverte dans un onglet.
Lancer
docker run -d --name web -p 8080:80 <image>- lancer un conteneur en arrière-plan, port ouvert
docker run --rm -it <image> sh- un conteneur jetable, pour essayer
docker run -e CLE=valeur <image>- donner une variable d'environnement
docker run --env-file .env <image>- toutes les variables d'un fichier
docker run -v donnees:/chemin <image>- brancher un volume
Regarder
docker ps / docker ps -a- ce qui tourne / aussi ce qui s'est arrêté
docker logs -f --tail 100 web- les logs, en direct
docker exec -it web sh- un terminal dans le conteneur
docker inspect web- tout son état
docker stats- CPU et mémoire en direct
Fabriquer une image
docker build -t mon-appli:1.0 .- fabriquer l'image depuis le Dockerfile
docker build --no-cache -t mon-appli .- tout refaire, sans cache
docker history mon-appli:1.0- ses couches et leur taille
docker images- les images sur ta machine
docker init- générer un Dockerfile, un compose.yaml, un .dockerignore
Arrêter et nettoyer
docker stop web / docker rm web- arrêter / supprimer le conteneur
docker rmi <image>- supprimer une image
docker system df- la place prise sur le disque
docker system prune- le ménage (les volumes sont épargnés)
docker volume ls / docker volume rm <nom>- lister / supprimer un volume, exprès
Compose
docker compose up -d- tout lancer
docker compose up -d --build- refabriquer, puis tout lancer
docker compose ps- l'état de chaque service
docker compose logs -f <service>- les logs d'un service
docker compose exec <service> sh- un terminal dans un service
docker compose config- le fichier final, variables remplacées
docker compose down- tout arrêter (les volumes restent)
docker compose down -v- tout arrêter ET supprimer les volumes
Relire un Dockerfile
FROM image:version-slim- une version précise, une variante légère
COPY <dépendances> → RUN install → COPY . .- les dépendances avant le code
.dockerignore- au moins .env et .git
USER <pas root>- le conteneur ne tourne pas en root
CMD ["programme", "arg"]- forme exec, entre crochets
FROM … AS build + COPY --from=build- multi-stage : les outils de build restent dehors
Les autres ressources
Gratuites, en français, sans email à donner.
- Le Starter Kit Claude CodeLes dix bases pour coder avec Claude Code.
- Écrire une skill Claude CodeComprendre les skills, puis écrire les tiennes.
- Les agents IA, brique par briqueModèle, outils, boucle, mémoire, permissions, MCP, skills, sous-agents : comprendre un agent, t'en servir, faire le tien. Avec l'antisèche.
- Une IA locale, sur ton ordiUne IA gratuite qui tourne sans internet : les liens, les trois étapes, et le piège à éviter avant de partir.
- La masterclass GitDe git init au cherry-pick : les commandes, le rebase, les workflows d'équipe. Avec l'antisèche.
- Ma stack qui ne coûte rienComment je range et héberge une dizaine de sites pour presque rien. Quel serveur choisir, les moins chers, et les 7 hébergeurs gratuits de 2026.
- Se lancer en freelanceLes règles à connaître avant de quitter ton CDI.
- La compétence en TLa base large, puis une spécialité : comment choisir la tienne.
- Devenir data engineerLa carte 2026, par quelqu'un qui fait ce métier : cinq étapes, un seul cloud.
- Apprendre AWSLes services qui servent vraiment, comment ils se branchent, la roadmap et cinq projets. Avec l'antisèche.
- Les jeux pour apprendre à coderLe SQL en résolvant un meurtre, le terminal Linux en fouillant un serveur, l'algorithmique en duel : ce que chaque jeu t'apprend, avec le prix et le niveau.
- 7 ressources gratuites pour la dataHarvard, le MIT, Google, l'État français : la liste complète, et dans quel ordre l'ouvrir.