Tu arrives tôt : c'est la toute première version du site, on y travaille encore.
DeviensDevInstagram

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.

1

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

  1. Ton appli
  2. Ses librairies
  3. Un système complet (avec son noyau)
  4. L'hyperviseur
  5. La machine et son système

Chaque VM démarre son propre système. Trois VM = trois systèmes à faire tourner.

Un conteneur

  1. Ton appli
  2. Ses librairies
  3. Docker
  4. 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.

Sur Mac et Windows, Docker Desktop fait tourner une petite machine virtuelle Linux en arrière-plan, parce que les conteneurs Linux ont besoin d'un noyau Linux. Tu ne la vois pas : tous tes conteneurs la partagent.

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.

2

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.

  1. Dockerfile

    La recette. Un fichier texte.

  2. Image

    Le moule. En lecture seule, ne bouge plus.

  3. Conteneurs

    • web-1
    • web-2
    • web-3

    Les gâteaux. Autant que tu veux, du même moule.

Tu modifies le code ? Tu ne modifies pas le conteneur : tu refabriques l'image, et tu relances des conteneurs neufs à partir d'elle.

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 machine

Docker 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 ».

3

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'imageCe qu'il y a dedansTaille (compressée, Docker Hub)
python:3.12Debian complet, avec les compilateurs et plein d'outils411 Mo
python:3.12-slimDebian réduit au minimum pour faire tourner Python46 Mo
python:3.12-alpineAlpine Linux, une distribution minuscule21 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 ».

4

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 /app

Sourcedocs.docker.com, « Storage » (couche inscriptible du conteneur) et « Volumes ».

5

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.

6

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 : interactif

Deux détails qui évitent des heures de recherche :

  • Ton appli doit écouter sur 0.0.0.0, pas sur localhost. Dans le conteneur, localhost veut 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:5432 ouvre 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-feu ufw : 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 ».

7

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 direct

Dans 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 ».

8

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, cache

docker 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.

9

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.0

Les instructions que tu croiseras le plus :

InstructionCe qu'elle faitQuand
FROML'image de départ.À la fabrication
WORKDIRLe dossier courant pour la suite.À la fabrication
COPYCopie des fichiers de ton projet dans l'image.À la fabrication
RUNLance une commande (installer un paquet, compiler).À la fabrication
ENVPose une variable d'environnement, gravée dans l'image.Fabrication et exécution
ARGUne variable qui n'existe que pendant la fabrication.À la fabrication
USERL'utilisateur qui lance la suite, et le conteneur.Fabrication et exécution
EXPOSEDocumente le port d'écoute. Ne l'ouvre pas.Documentation
CMD / ENTRYPOINTCe que le conteneur lance au démarrage.À l'exécution
HEALTHCHECKLa 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 »).

10

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.pyTu ajoutes une librairie
FROM python:3.12-slimen cacheen cache
WORKDIR /appen cacheen cache
COPY requirements.txt .en cacherefait
RUN pip install …en cacherefait
COPY . .refaitrefait
RUN useradd … / USER / CMDrefaitrefait
Lu de haut en bas : dès qu'une couche change, toutes celles d'en dessous se refont. Tu changes ton code dix fois par jour, tes dépendances une fois par semaine : les dépendances d'abord, le code ensuite.
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 taille

Deux 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 RUN que l'installation : apt-get update && apt-get install … && rm -rf /var/lib/apt/lists/*.
  • apt-get update seul sur sa ligne est un piège : sa couche reste en cache, et un apt-get install ajouté plus tard part d'une liste de paquets périmée. La doc demande de les mettre dans le même RUN.

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.

11

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 :

  1. Les secrets. Sans lui, ton .env part dans l'image au premier COPY . ..
  2. Le cache. Sans lui, toucher n'importe quel fichier (un log, un fichier du .git) change la couche COPY . . et relance tout ce qui suit.
  3. La taille et la justesse. Tes node_modules ont été installés pour ton Mac ; l'image doit installer les siens, pour Linux.

Sourcedocs.docker.com, « Build context », § .dockerignore files.

12

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/html

Node, 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.

Sourcedocs.docker.com, « Multi-stage builds ».

13

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érifiesPourquoiCe que tu veux voir
1. Le FROM a une version préciselatest ou pas de tag : la base change sans prévenir (section 3).python:3.12-slim, node:24-slim
2. Une variante légèreL'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 codeSinon chaque modif de code réinstalle tout (section 10).COPY requirements.txt ou package*.json, RUN install, puis COPY . .
4. Un .dockerignore existeSans lui, COPY . . embarque .env et .git (section 11).Le fichier, avec au moins .env et .git
5. Aucun secret dans l'imageUne 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 RUNSé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 rootSans 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 crochetsEn 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 demandeVoir le test juste en dessous.Un serveur qui gère l'arrêt, ou --init / init: true
10. Un seul rôle par conteneurLa 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 ouvertureIl 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 compilationSinon 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éedocker 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 --init0,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.

14

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-moi

Tu 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 ».

15

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 fichierQui peut joindre le port
RienLes 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).

16

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 conditionCompose attend que…
service_started (défaut)le conteneur soit lancé. C'est tout.
service_healthyson healthcheck passe. Il faut donc un healthcheck.
service_completed_successfullyle 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 avant unhealthy (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.

17

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 avec

J'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.

18

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, puis up : le site que j'avais déclaré dans Umami est toujours là. down -v, puis up : plus rien, retour au compte admin / 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.

19

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

Ton site n'est pas dans le dessin : il tourne ailleurs. C'est le navigateur de ton visiteur qui parle à Umami, directement. Tant qu'Umami tourne sur ton ordi, tu es le seul à pouvoir le joindre.

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 fichierCe 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:5432L'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: trueLes 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, un CMD entre crochets. J'ai vérifié dans le conteneur : whoami répond nextjs, pas root.
  • Un healthcheck sur l'appli aussi, pas seulement sur la base. docker compose ps affiche healthy pour 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 ».

20

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 :

  1. Le tag. latest bouge sans prévenir (section 3). Aujourd'hui, latest et 3.4.0 sont la même image. Écris 3.4.0 : c'est toi qui choisis le jour de la mise à jour.
  2. Les secrets. APP_SECRET protège les jetons de connexion : la doc d'Umami demande une valeur unique par installation. Pareil pour la clé de la double authentification.
  3. Les mots de passe. Celui de la base (umami, écrit deux fois dans le fichier) part dans le .env. Celui du compte admin se change dans l'interface, dès la première connexion.
  4. Le port. "3000:3000" ouvre l'appli à tout internet, en HTTP, et sans passer par ton pare-feu (section 6). Avec 127.0.0.1 devant, 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 ici

Je 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 ».

21

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.

  1. Dans Umami, ouvre Websites, puis Add website. Donne un nom, et le domaine de ton site.
  2. Clique sur Edit en face de ce site. Sous Tracking code, Umami te donne une ligne de script, avec l'identifiant du site dedans.
  3. 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 ».

22

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.

Sourcekubernetes.io, « Qu'est-ce que Kubernetes ? ».

Les 8 pièges

Ce que tout le monde fait au moins une fois, et ce qu'il faut faire à la place.

  1. 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. 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. 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. 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. 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. 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. 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. 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.