Docker sur un VPS : installation, Compose et pièges de pare-feu

Docker s'installe en trois commandes. Le piège arrive après : un port publié par Docker traverse UFW sans le voir. Explication et correctif.

Pile de conteneurs derrière un mur de pare-feu avec un seul port ouvert

Docker rend un VPS immédiatement utile : une stack complète, application, base de données, cache, proxy, tient dans un fichier versionné et se redéploie à l'identique. L'installation prend trois minutes. Ce qui mérite votre attention vient après : la gestion des journaux, la persistance, et surtout la manière dont Docker interagit avec votre pare-feu.

1. Installer Docker Engine

Le paquet docker.io des dépôts Ubuntu retarde souvent de plusieurs versions. Utilisez le dépôt officiel : c'est aussi le seul qui fournit le greffon Compose v2 à jour.

bash
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
bash
sudo docker run --rm hello-world
docker compose version

2. Utiliser Docker sans sudo

bash
sudo usermod -aG docker $USER
newgrp docker
docker ps

À mesurer : appartenir au groupe docker équivaut à un accès root sur la machine, un conteneur peut monter le système de fichiers hôte. Sur une VM partagée par plusieurs personnes, préférez sudo docker ou le mode rootless.

3. Limiter la croissance des journaux

Par défaut, Docker conserve la totalité de la sortie standard de chaque conteneur, sans rotation. Un service bavard peut remplir un disque de 40 Go en quelques semaines, c'est la panne la plus banale sur un VPS conteneurisé.

bash
sudo tee /etc/docker/daemon.json <<'EOF'
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" },
  "live-restore": true
}
EOF

sudo systemctl restart docker

La limite ne s'applique qu'aux conteneurs créés ensuite ; recréez les existants pour qu'elle prenne effet.

4. Le piège : Docker contourne UFW

C'est le point qui surprend le plus. Vous avez configuré ufw default deny incoming, vous lancez :

bash
docker run -d -p 5432:5432 postgres:16

…et votre base de données est accessible depuis Internet. Docker insère ses propres règles NAT directement dans la chaîne PREROUTING de netfilter, en amont des chaînes où UFW opère. Le trafic est traduit avant d'être filtré. sudo ufw status continue d'afficher un pare-feu fermé, et c'est exact, il n'est simplement pas sur le chemin.

La solution la plus simple : ne publiez jamais sur toutes les interfaces.

bash
# au lieu de -p 5432:5432
docker run -d -p 127.0.0.1:5432:5432 postgres:16

Seuls le proxy et les services locaux atteignent alors le port. En Compose :

yaml
services:
  db:
    image: postgres:16
    ports:
      - "127.0.0.1:5432:5432"

Si un port doit rester public mais filtré par source, écrivez la règle dans la chaîne DOCKER-USER, que Docker consulte avant ses propres règles et ne réécrit jamais :

bash
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport 5432 \
     -s 203.0.113.0/24 -j ACCEPT
sudo iptables -I DOCKER-USER -i ens3 -p tcp --dport 5432 -j DROP
sudo apt install -y iptables-persistent   # pour survivre au redémarrage

Vérifiez toujours depuis l'extérieur, jamais depuis la machine : nmap -Pn -p 5432 198.51.100.42 depuis un autre hôte donne la réponse qui compte.

5. Une première stack Compose

Un exemple complet : un proxy Caddy qui obtient et renouvelle seul ses certificats, devant une application. Seuls les ports 80 et 443 sont publiés.

compose.yaml
# /srv/monapp/compose.yaml
services:
  proxy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    depends_on:
      - app

  app:
    image: ghcr.io/exemple/monapp:1.4.2
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy

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

volumes:
  caddy_data:
  pgdata:
bash
cd /srv/monapp
echo "DB_PASSWORD=$(openssl rand -base64 24)" > .env
chmod 600 .env
docker compose up -d
docker compose ps

Trois détails qui font la différence sur la durée : restart: unless-stopped relance les conteneurs après un redémarrage de la VM ; les tags d'image sont figés plutôt que latest, pour que docker compose pull reste une décision consciente ; les données vivent dans des volumes nommés, pas dans la couche du conteneur.

6. Exploitation courante

bash
docker compose logs -f --tail=100 app   # suivre un service
docker compose pull && docker compose up -d   # mettre à jour
docker compose exec db psql -U app      # entrer dans un conteneur
docker system df                        # ce que Docker occupe sur le disque
docker system prune -a --volumes        # ⚠ supprime aussi les volumes orphelins

Sauvegardes : un snapshot de VM capture l'état du disque, y compris une base de données en cours d'écriture. Pour une restauration fiable, doublez-le d'un pg_dump planifié vers un stockage distinct. Le snapshot vous ramène la machine ; le dump vous ramène des données cohérentes.

Checklist

  • Docker installé depuis le dépôt officiel, Compose v2 disponible.
  • Rotation des journaux configurée dans daemon.json.
  • Aucun port publié sur 0.0.0.0 sans intention explicite.
  • Exposition vérifiée depuis une machine extérieure.
  • restart: unless-stopped sur tout ce qui doit survivre à un redémarrage.
  • Volumes nommés pour les données, sauvegarde logique en complément du snapshot.