SSD Nodes Learn
Guides Matt ConnorPar Matt Connor · Mis à jour le 2026-07-19

Docker Compose : les bases pour votre VPS

Installez Docker Engine et Compose v2 sur Ubuntu 24.04, rédigez un vrai compose.yml à deux services, évitez le piège des ports ufw et sauvegardez vos volumes.

Ce que vous allez construire

Docker Compose est le socle sous presque tout le reste de ce site. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat : chacun de ces guides commence par « écrivez ce fichier compose », et voici la page qui explique ce que ce fichier veut vraiment dire. Vous allez installer Docker Engine et le plugin Compose v2 depuis le dépôt apt de Docker sur Ubuntu 24.04, puis monter une vraie pile à deux services, Miniflux, un petit lecteur RSS, plus PostgreSQL, car ce duo met en œuvre tous les motifs qu'utilisent les applications plus grosses : des images épinglées, une base de données avec un healthcheck, un volume nommé, des secrets dans un fichier .env, et un port publié uniquement sur localhost.

L'installation prend cinq minutes. Le reste de ce guide couvre les parties qui font mal plus tard : le groupe docker qui est root sous un autre nom, les ports publiés qui passent tout droit devant vos règles ufw, et le seul drapeau de docker compose down qui supprime votre base de données sans aucune demande de confirmation.

Prérequis : un VPS KVM Ubuntu 24.04 tout neuf, un utilisateur avec sudo, et un gigaoctet de RAM ou plus. Une installation Docker déjà présente convient aussi : la première section explique ce qu'il faut retirer.

Installez depuis le dépôt de Docker, pas celui d'Ubuntu

Deux mauvais virages à refuser avant la première commande. Le paquet docker.io d'Ubuntu fonctionne, mais il est en retard sur les versions de Docker et ne suit pas l'agencement en plugins que tout le reste suppose. Et le binaire autonome docker-compose, celui avec le trait d'union, c'est Compose v1 : du Python, en fin de vie depuis 2023, et la raison pour laquelle les vieux tutoriels cassent. Compose aujourd'hui, c'est docker compose avec une espace, un plugin de la CLI, installé depuis le même dépôt que le moteur.

Si l'un de ces éléments est déjà sur la machine, nettoyez-le d'abord, y compris docker-compose-v2, l'empaquetage du plugin par Ubuntu, pour que tout provienne d'un seul dépôt :

sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc

Package 'docker.io' is not installed, so not removed est la sortie normale sur un VPS tout neuf. Ajoutez ensuite le dépôt de Docker et installez :

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Vérifiez les trois couches :

docker --version
docker compose version
sudo docker run --rm hello-world

Les deux premières commandes affichent des chaînes de version : Docker Compose version v2.x.x confirme que vous avez le plugin, pas le binaire v1 mort. L'exécution de hello-world doit se terminer par Hello from Docker!. Le paquet active le service au démarrage ; systemctl is-enabled docker affiche enabled.

Le groupe docker, c'est root : décidez en connaissance de cause

Pour l'instant chaque commande docker a besoin de sudo, car la socket du démon à /var/run/docker.sock appartient à root et au groupe docker. Sans en être membre vous obtenez l'erreur Docker la plus recherchée sur Google :

permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock

Le correctif standard :

sudo usermod -aG docker $USER

L'appartenance à un groupe s'applique à la connexion, donc l'erreur persiste dans votre shell actuel. Lancez newgrp docker pour cette session, ou déconnectez-vous puis reconnectez-vous ; id doit alors lister docker parmi vos groupes.

Maintenant la partie honnête, dite clairement : être membre du groupe docker, c'est être root sur l'hôte. Pas « quasi root », pas « élevé » : root. Quiconque est dans ce groupe peut lancer docker run --rm -it -v /:/host alpine chroot /host et s'emparer de tout le système de fichiers, sans aucun mot de passe. Le groupe existe pour la commodité, pas pour l'isolement.

Le mode rootless de Docker est la vraie alternative : le démon lui-même tourne sous votre utilisateur non privilégié. Cela vous coûte quelque chose : les ports en dessous de 1024 demandent une configuration supplémentaire, le réseau passe par une couche en espace utilisateur avec un surcoût mesurable, et certaines images se comportent mal sans un vrai root. Sur un VPS à administrateur unique, où la seule connexion possède déjà sudo, le groupe ne change rien en pratique, et c'est ce que suppose chaque guide ici : ne le distribuez simplement jamais comme s'il valait moins que sudo.

Anatomie d'un fichier compose

Donnez à chaque pile son propre répertoire : le nom du répertoire devient le nom du projet, qui préfixe les conteneurs, les réseaux et les volumes :

sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/miniflux

Créez compose.yml (le nom moderne ; docker-compose.yml fonctionne encore). Sautez l'ancienne clé version: : elle est obsolète et Compose vous avertit s'il en voit une.

services:
  miniflux:
    image: miniflux/miniflux:2.2.9
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      - DATABASE_URL=postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
      - RUN_MIGRATIONS=1
      - CREATE_ADMIN=1
      - ADMIN_USERNAME=admin
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=miniflux
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=miniflux
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db-data:

Chaque ligne ci-dessus est une décision. Prenons-les une par une.

Épinglez les versions d'image : :latest plus un pull, c'est une mise à niveau non surveillée

postgres:16-alpine, pas postgres:latest. Un tag n'est pas figé : :latest se résout à nouveau vers ce que le mainteneur a poussé le plus récemment, à chaque pull. Combinez cela avec l'habitude de mise à niveau de routine que vous êtes sur le point d'apprendre, docker compose pull && docker compose up -d, et :latest signifie que des sauts de version majeure débarquent quand l'amont les publie, pas quand vous le choisissez. Avec PostgreSQL ce n'est pas hypothétique : un saut surprise de 16 à 17 laisse le conteneur en boucle de plantage sur un répertoire de données incompatible, car les mises à niveau majeures de Postgres exigent un dump et une restauration, pas un redémarrage.

Épinglez au moins la version majeure (postgres:16-alpine suit les correctifs 16.x), et épinglez les applications à une version exacte comme miniflux/miniflux:2.2.9 : consultez la page des versions du projet et utilisez celle qui est actuelle au moment où vous écrivez le fichier. Une mise à niveau devient alors une modification d'une ligne que vous avez faite exprès, visible dans git diff.

Publiez sur 127.0.0.1, parce que Docker contourne ufw

"127.0.0.1:8080:8080" : adresse hôte, port hôte, port conteneur. La plupart des tutoriels écrivent "8080:8080", qui est un raccourci pour 0.0.0.0:8080:8080 : à l'écoute sur toutes les interfaces, y compris l'interface publique.

Voici le piège, et il mord presque tout le monde une fois. Docker publie un port en écrivant une règle DNAT qui réécrit la destination du paquet vers l'IP interne du conteneur avant le filtrage, si bien que le paquet emprunte le chemin FORWARD et ne touche jamais INPUT, là où vivent vos règles ufw. sudo ufw deny 8080 signale un succès, ufw status montre le port refusé, et le service répond quand même à internet tout entier. Votre pare-feu n'est pas cassé ; il est contourné par conception. Pourquoi Docker contourne ufw, et comment filtrer pour de vrai le trafic des conteneurs détaille le mécanisme et le correctif DOCKER-USER pour les ports qui doivent rester publics.

L'habitude qui fait disparaître tout le problème : liez les ports publiés à 127.0.0.1 sauf raison précise de faire autrement, et placez un proxy inverse devant tout ce qui doit faire face au monde. C'est exactement ce que construit le guide du proxy inverse Traefik comme étape suivante après cette page : un conteneur qui possède les ports 80 et 443 et route vers tout le reste par nom d'hôte, avec TLS. (Vous venez d'une ancienne configuration Traefik v2 ? Le guide de migration de Traefik v2 vers v3 couvre les renommages et les changements de règles.)

Vérifiez le lien après avoir démarré la pile : sudo ss -tlnp | grep 8080 doit montrer 127.0.0.1:8080, pas 0.0.0.0:8080 ni *:8080.

Volumes nommés contre montages liés

db-data:/var/lib/postgresql/data est un volume nommé : Docker crée et gère un répertoire sous /var/lib/docker/volumes/ et le monte dans le conteneur. L'alternative est un montage lié, ./data:/var/lib/postgresql/data, qui associe un chemin que vous avez choisi sur l'hôte.

La répartition qui tient en pratique : des volumes nommés pour les données que seuls les conteneurs manipulent, les bases de données avant tout, puisque Docker initialise le volume avec la propriété qu'attend l'image et les permissions de fichiers fonctionnent d'emblée. Des montages liés pour les fichiers que vous manipulez depuis l'hôte : les fichiers de configuration que vous éditez avec un éditeur de texte, une médiathèque que vous synchronisez par rsync, tout ce dont vous voulez que le chemin soit évident. L'échec classique du montage lié est la propriété : le conteneur tourne sous l'UID 999, votre répertoire hôte appartient à l'UID 1000, et l'application meurt au démarrage avec permission denied dans ses journaux. Les volumes nommés font surtout disparaître cette catégorie de bug, au prix de données qui vivent à un chemin géré par Docker, abordé plus bas.

environment et .env : gardez les secrets hors de git

${POSTGRES_PASSWORD} n'est pas lu depuis votre shell ; Compose l'interpole depuis un fichier nommé .env posé à côté de compose.yml. Créez-le :

cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignore

Générez de vraies valeurs avec openssl rand -hex 24. De l'hexadécimal, pas du base64, exprès : ce mot de passe atterrit à l'intérieur de la chaîne de connexion DATABASE_URL, et les caractères /, + et = que produit le base64 cassent l'analyse de l'URL, une panne qui se manifeste par une erreur d'authentification, pas une erreur de syntaxe, et vous coûte une soirée. La ligne .gitignore va avant le premier commit : le fichier compose est sûr à publier et à versionner, le fichier .env ne l'est jamais, et un secret qui a touché l'historique de git est un secret que vous faites tourner. Si vous démarrez la pile avec une variable manquante, Compose avertit bruyamment et continue avec une chaîne vide, ce qui pour un mot de passe Postgres signifie un déploiement cassé :

WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.

docker compose config affiche le fichier entièrement interpolé, le moyen le plus rapide de vérifier ce que les conteneurs recevront réellement ; rappelez-vous que sa sortie inclut vos secrets.

depends_on n'attend rien, sauf si vous ajoutez un healthcheck

Un simple depends_on: [db] contrôle uniquement l'ordre de démarrage : Compose lance Postgres d'abord et l'application un instant plus tard, alors que Postgres est encore à quelques secondes d'accepter des connexions. L'application atteint la base de données, échoue, et plante ou réessaie selon la qualité de son écriture.

La version fiable est celle qu'utilise le fichier ci-dessus : le service db définit un healthcheck (Postgres fournit pg_isready exactement pour cela), et l'application déclare depends_on avec condition: service_healthy. Compose démarre la base de données, interroge le contrôle toutes les 10 secondes, et ne démarre Miniflux qu'une fois le contrôle réussi. Si la base de données ne devient jamais saine, mauvais mot de passe, volume corrompu, l'application ne démarre jamais et Compose vous dit quelle dépendance a échoué :

dependency failed to start: container miniflux-db-1 is unhealthy

Ce message vous oriente vers docker compose logs db, là où vit la vraie erreur.

restart: unless-stopped

restart: unless-stopped sur les deux services signifie que les conteneurs reviennent après un plantage et après un redémarrage du VPS, mais restent arrêtés si vous avez délibérément lancé docker compose stop. L'alternative always ressuscite les conteneurs même après un arrêt manuel, rarement ce que vous vouliez. Sans politique de redémarrage, un redémarrage pour mise à jour du noyau à 4 h du matin met discrètement vos services hors service jusqu'à ce que vous le remarquiez.

Les verbes du quotidien

Tout ce qui est quotidien tient en cinq commandes, lancées depuis le répertoire du projet.

docker compose up -d        # create and start; idempotent, recreates only what changed
docker compose ps           # status, ports, and health of this project's containers
docker compose logs -f miniflux   # follow one service's logs; --tail 100 for recent history
docker compose pull && docker compose up -d   # upgrade to the pinned tags
docker compose down         # stop and remove containers and the network

up -d peut être relancée sans risque : elle compare le fichier à la réalité et ne touche que les services dont la configuration ou l'image a changé. Le duo de mise à niveau récupère ce que vos tags épinglés pointent désormais : les correctifs sous postgres:16-alpine, rien pour une épingle exacte tant que vous ne l'éditez pas, ce qui est le but. Les anciennes images s'accumulent après les mises à niveau ; récupérez de l'espace disque avec docker image prune -f.

Maintenant la commande destructrice, dite haut et fort : docker compose down est sûre, les conteneurs et le réseau sont jetables, et vos données sont dans le volume. docker compose down -v supprime aussi les volumes nommés. C'est votre base de données, disparue, instantanément, sans demande de confirmation et sans retour en arrière. Le drapeau -v existe pour démonter des expériences ; sur une pile qui contient de vraies données, traitez-le comme vous traitez rm -rf. Il n'y a pas de corbeille sous /var/lib/docker/volumes/.

Pour un shell ponctuel dans un conteneur en cours d'exécution : docker compose exec db psql -U miniflux vous dépose dans la base de données, et docker compose exec miniflux sh vous donne un shell dans l'application.

Où vivent réellement vos données

Les volumes nommés reçoivent le préfixe du projet, si bien que db-data dans un répertoire nommé miniflux devient miniflux_db-data :

docker volume ls
docker volume inspect miniflux_db-data

La sortie de inspect inclut la ligne qui compte :

"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"

Ce répertoire est la base de données : détenu par root, sur le système de fichiers de l'hôte, et il survit à down, aux mises à niveau et aux reconstructions de conteneurs. C'est aussi exactement ce que vos sauvegardes doivent capturer.

Sauvegarder un volume nommé

Le motif standard est un conteneur jetable qui monte le volume en lecture seule à côté d'un répertoire hôte, et fait un tar à travers :

docker run --rm \
  -v miniflux_db-data:/data:ro \
  -v "$PWD":/backup \
  alpine:3.22 tar czf /backup/miniflux-db-$(date +%F).tar.gz -C /data .

Aucune installation, rien qui reste en cours d'exécution, et la restauration est l'image miroir : tar xzf dans un volume vide et neuf avec les mêmes montages inversés.

Une réserve pour les bases de données : archiver avec tar un répertoire de données Postgres en cours d'exécution peut capturer un état en pleine écriture qui ne démarrera pas proprement. Soit docker compose stop le temps des quelques secondes que prend le tar, soit, mieux, prenez un dump logique, qui est cohérent par construction :

docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz

Le -T désactive le pseudo-terminal que Compose alloue par défaut ; faire passer la sortie du dump par un TTY peut la corrompre. Mettez l'une de ces commandes dans cron et copiez le résultat hors du VPS ; une sauvegarde sur le même disque que les données qu'elle protège est une copie, pas une sauvegarde. Le guide Nextcloud construit une routine planifiée complète autour de ces deux motifs exacts.

Modes de défaillance, avec les chaînes que vous verrez

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock : vous n'êtes pas encore dans le groupe docker, ou vous y êtes mais la session est antérieure à cet ajout. id montre vos groupes effectifs ; newgrp docker corrige le shell actuel, se déconnecter puis se reconnecter les corrige tous.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? : problème différent, le démon lui-même est arrêté. sudo systemctl status docker et sudo journalctl -u docker -n 50 disent pourquoi. Sur un VPS la cause classique est un disque plein : df -h /var/lib/docker d'abord.

Bind for 127.0.0.1:8080 failed: port is already allocated : un autre conteneur a déjà publié ce port hôte. docker ps montre lequel ; un conteneur périmé issu d'un docker run expérimental il y a des semaines est le coupable habituel. Si docker ps est propre, un processus hors Docker détient le port : sudo ss -tlnp | grep 8080 le nomme.

yaml: line 14: did not find expected key : une erreur d'indentation à la ligne nommée ou juste au-dessus. Les fichiers Compose sont du YAML : indentation de deux espaces, des espaces uniquement, et un caractère de tabulation où que ce soit est fatal. docker compose config valide le fichier sans rien démarrer, et le lancer après chaque modification est une habitude peu coûteuse.

La surprise ufw n'affiche aucune erreur, et c'est ce qui la rend dangereuse : le déploiement fonctionne, ufw status a l'air correct, et un scan de ports depuis l'extérieur trouve quand même votre base de données. Relisez la section sur les ports ci-dessus, vérifiez chaque entrée ports: pour un préfixe 127.0.0.1: manquant, et confirmez depuis une autre machine avec curl http://your-vps-ip:8080 : connexion refusée est la réponse que vous voulez.

À partir d'ici, le guide Traefik transforme cette pile unique en plusieurs applications derrière un seul point d'entrée HTTPS, et ce qui vaut la peine d'être auto-hébergé en 2026 est la liste de courses à y faire passer.

Un serveur de jeu comme un serveur Minecraft sur un VPS est un premier projet Compose sympathique sur lequel s'entraîner.

FAQ

Pourquoi ai-je « permission denied while trying to connect to the Docker daemon socket » ?

Votre utilisateur n'est pas dans le groupe docker, ou y a été ajouté après le début de la session actuelle : l'appartenance ne s'applique qu'à la connexion. Lancez sudo usermod -aG docker $USER, puis newgrp docker ou déconnectez-vous et reconnectez-vous, et confirmez avec id. Le groupe accorde un accès équivalent à root sur l'hôte, donc n'ajoutez que des utilisateurs à qui vous donneriez sudo.

Est-ce que docker compose down supprime mes données ?

Un simple docker compose down ne le fait pas : il retire les conteneurs et le réseau du projet ; les volumes nommés survivent et le prochain up -d les rattache. docker compose down -v est la forme destructrice : il supprime les volumes nommés, c'est-à-dire votre base de données, sans confirmation et sans retour en arrière. Ne lancez jamais -v sur une pile contenant de vraies données à moins de détenir une sauvegarde vérifiée.

Quelle est la différence entre docker-compose et docker compose ?

docker-compose (trait d'union) est Compose v1, un binaire Python autonome qui a atteint sa fin de vie en 2023 et ne devrait pas être installé sur de nouveaux serveurs. docker compose (espace) est Compose v2, un plugin Go pour la CLI de Docker, installé comme docker-compose-plugin depuis le dépôt apt de Docker. Les commandes et le YAML sont presque totalement compatibles, donc quand un vieux tutoriel dit docker-compose up, tapez docker compose up.

Pourquoi puis-je atteindre mon conteneur Docker depuis internet alors que ufw bloque le port ?

Parce que Docker publie les ports avec des règles DNAT dans la chaîne PREROUTING d'iptables, et les paquets réécrits empruntent le chemin FORWARD à travers les propres chaînes de Docker : ils ne touchent jamais la chaîne INPUT où s'appliquent les règles d'ufw. ufw deny 8080 ne fait donc rien à un port de conteneur publié. Corrigez-le à la source : publiez sur 127.0.0.1: et exposez les services à travers un proxy inverse.

Devrais-je utiliser un volume nommé ou un montage lié ?

Des volumes nommés pour les données que seul le conteneur manipule, les bases de données surtout, puisque Docker fixe la propriété qu'attend l'image et les permissions fonctionnent d'emblée. Des montages liés pour les fichiers que vous manipulez aussi depuis l'hôte : les configurations que vous éditez, les médias que vous téléversez, tout ce dont vous voulez que le chemin soit évident. Si un conteneur échoue au démarrage avec permission denied sur un montage lié, une incompatibilité d'UID entre l'hôte et le conteneur est la première chose à vérifier.