Skip to main content

Gestion du réseau et des ports Docker

Cette page est dédier à la question suivante :

Comment bien configuré ses conteneurs sur le réseau ?

Portée : Docker Compose, hosts Linux avec UFW comme pare-feu, reverse proxy type Traefik/Caddy/nginx.
Objectif : standardiser une architecture réseau 100 % maîtrisée sur toutes les stacks compose.

1. Comportement par défaut de Docker

1.1 Réseaux créés automatiquement

Par défaut, docker compose up crée un réseau bridge dédié par projet (nommé <nom_projet>_default), distinct du bridge docker0 historique. 
Tous les services d'un même fichier compose sont attachés à ce réseau et se résolvent entre eux par nom de service (DNS interne Docker), sans avoir besoin de publier le moindre port.

➡️ Conséquence clé : la communication inter-conteneurs ne nécessite jamais ports:. 
ports: ne sert qu'à exposer un service en dehors du host Docker (vers le LAN/WAN).

1.2 EXPOSE vs ports:
Directive Effet réel Portée
EXPOSE (Dockerfile) Documentation uniquement, aucun binding réseau réel N/A
expose: (compose) Idem, informatif N/A
ports: (compose) Publie réellement le port sur une interface du host via NAT Host + extérieur
1.3 Docker et iptables : le piège UFW

C'est le point le plus critique en environnement Ops/DevSec.

Quand un conteneur publie un port avec ports:, Docker insère automatiquement des règles dans les chaînes DOCKER et DOCKER-USER de la table nat/filter d'iptables — en amont de la chaîne INPUT gérée par UFW.

Conséquence : un port publié via ports: est accessible depuis l'extérieur même si UFW le bloque explicitement. 
UFW ne voit jamais passer ce trafic car Docker route directement via NAT/FORWARD.

Trafic entrant → PREROUTING (Docker DNAT) → FORWARD → DOCKER-USER → DOCKER → conteneur
                                                ↑
                                    UFW (INPUT) n'intercepte PAS ce chemin


2. Le comportement de ports: en détail

2.1 Syntaxes
ports:
  - "8080:80"            # host:container — bind 0.0.0.0 (TOUTES interfaces) par défaut, port ouvert sur iptables
  - "127.0.0.1:8080:80"  # bind explicite localhost uniquement
  - "10.10.10.1:8080:80" # bind sur une IP précise (ex: interface WireGuard)
  - "8080:80/udp"        # protocole explicite, port ouvert sur iptables
    2.2 Le piège du binding implicite

    "8080:80" publie sur 0.0.0.0:8080, donc sur toutes les interfaces réseau du host (LAN, WAN, VPN...), pas seulement en local.

    C'est la cause n°1 des services/ports exposées par erreur sur Internet.


    3. Bonnes pratiques Ops / DevSec

    • Principe de moindre exposition : ne publier (ports:) que ce qui doit être joignable depuis l'extérieur du host. Tout le reste communique via le réseau interne Docker (DNS de service).
    • Un réseau dédié par stack, jamais le réseau default partagé pour plusieurs projets sans raison. Nommer explicitement :
    networks:
        proxy-web:
          name: proxy-public # Destiné à exposé les services sur les ports 80/443
        backend:
          name: <stack>-internal # Destiné a rester en local, jamais exposé publiquement mais reste accessbles pour les autres services internes
          internal: true
    Type d’exposition  Type de service
    internal: true DB / cache /  workers / Clamav
    proxy-public Traefik/ Caddy/ nginx
    network_mode: host VPN

    Éviter network_mode: host sauf nécessité technique réelle (ex: WireGuard qui a besoin d'accéder aux interfaces réseau du host). Cas contraire, host désactive toute l'isolation réseau Docker.

    Convention de nommage des réseaux cohérente sur toutes les stacks (<stack>-internal, proxy-public, vpn-admin...) pour faciliter l'audit avec docker network ls.


    4. Commandes utiles pour l'audit

    4.1 Depuis Docker
    # Ports publiés par tous les conteneurs actifs
    docker ps --format "table {{.Names}}\t{{.Ports}}"
    
    # Ports publiés par un conteneur précis
    docker port <container>
    
    # Lister les réseaux et repérer les réseaux "trop partagés"
    docker network ls
    
    # Détail d'un réseau : conteneurs attachés, subnet, internal ou non
    docker network inspect <network>
    
    # Vue globale via l'API compose
    docker compose ps
    4.2 Depuis le host (vérité terrain, indépendante de Docker)
    # Tous les sockets en écoute, avec le PID/process
    ss -tulpn
    
    # Équivalent netstat
    sudo netstat -tulpn
    
    # Règles NAT injectées par Docker (bindings réels)
    sudo iptables -t nat -L DOCKER -n --line-numbers
    
    # Règles de filtrage custom (celles qui priment sur UFW)
    sudo iptables -L DOCKER-USER -n --line-numbers
    
    # Vérification externe depuis une autre machine (la vraie preuve)
    nmap -Pn -p- <ip_du_host>

    iptables sur le host est la seule vérité fiable : la lecture des docker-compose.yml seule ne garantit pas ce qui est réellement exposé (héritage de configs, overrides, etc.).

    Explication de la sortie affichée par la commande  sudo iptables -t nat -L DOCKER -n -v --line-numbers :
    Lecture des colonnes (avec -v, qui ajoute compteurs et interfaces — utile pour vérifier qu'une règle est réellement traversée) :

    Colonne Signification
    num Numéro de la règle dans la chaîne (utile pour cibler une règle précise avec -D DOCKER-USER <num>)
    pkts Nombre de paquets ayant matché cette règle depuis le dernier reset des compteurs — un 0 persistant indique une règle jamais déclenchée (donc potentiellement inutile ou mal placée)
    bytes Volume total de données ayant matché la règle (utile pour repérer un trafic anormal sur un port)
    target Action appliquée : DNAT (redirection vers l'IP/port du conteneur), ACCEPT, DROP, RETURN, ou le nom d'une sous-chaîne (ex: un nom de réseau Docker généré)
    prot Protocole concerné : tcp, udp, ou all
    opt Options additionnelles de la règle (généralement -- si aucune)
    in Interface réseau d'entrée du paquet (ex: eth0, wg0, br-xxxxx) — * = toutes interfaces
    out Interface réseau de sortie — * = toutes interfaces
    source Adresse IP/subnet source du paquet — 0.0.0.0/0 = n'importe quelle source
    destination Adresse IP/subnet de destination — 0.0.0.0/0 = n'importe quelle destination
    to: (en fin de ligne, table nat) Présent uniquement sur les règles DNAT : indique l'IP:port réel du conteneur vers lequel le trafic est redirigé (ex: to:172.17.0.3:80) — c'est cette colonne qui révèle le binding effectif d'un ports:

    Point de vigilance particulier : sur la chaîne DOCKER (table nat), une règle avec source 0.0.0.0/0 et une colonne to: renseignée confirme qu'un port est bien publié en NAT vers un conteneur — indépendamment de ce qu'affiche UFW, qui n'intercepte pas ce chemin.

     


    5 Exemples par cas d'usage

    5.1 Postgres non exposé
    services:
      postgres:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
        networks:
          - bdd-internal
        # AUCUN "ports:" — accessible uniquement via le réseau interne
    
      app:
        image: mon-app
        networks:
          - bdd-internal
        depends_on:
          - postgres
        # se connecte à "postgres:5432" via le DNS interne Docker
    
    networks:
      bdd-internal:
        internal: true   # aucune route sortante/entrante possible
    5.2 Reverse proxy avec réseau dédié
    services:
      traefik:
        image: traefik:v3
        ports:
          - "80:80"
          - "443:443"        # seul conteneur exposé sur 0.0.0.0
        networks:
          - proxy-public
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock:ro
    
      webapp:
        image: mon-webapp
        networks:
          - proxy-public   # visible par Traefik
          - bdd-internal   # accès DB
        labels:
          - traefik.enable=true
          - traefik.http.routers.webapp.rule=Host(`app.example.com`)
        # AUCUN "ports:" sur webapp — tout passe par Traefik
    
      postgres:
        image: postgres:16
        networks:
          - bdd-internal   # jamais sur proxy-public
    
    networks:
      proxy-public:
        name: proxy-public
        external: true   # réseau partagé, créé une fois, rejoint par chaque stack
      bdd-internal:
        internal: true

    Le service webapp n'a pas de ports: du tout : il n'est joignable que via Traefik, lui-même seul point d'entrée public du host.

    Cas particulier : le reverse proxy tourne **directement sur le host** (pas en conteneur). Il ne peut pas "rejoindre" un network Docker comme le ferait un conteneur — deux approches valables selon le niveau de rigueur voulu.

    Solution : Chaque service publie son port uniquement sur `127.0.0.1`, Apache proxy vers localhost :

    services:
      webapp:
        image: mon-webapp
        ports:
          - "127.0.0.1:8081:80"   # jamais 0.0.0.0
        networks:
          - bdd-internal
    <VirtualHost *:443>
        ServerName app.example.com
        ProxyPass / http://127.0.0.1:8081/
        ProxyPassReverse / http://127.0.0.1:8081/
    </VirtualHost>

    Avantage : simple, aucune règle iptables custom nécessaire (un socket bindé en loopback n'est routable par personne d'autre que le host lui-même). 

    5.3 VPN WireGuard : accès admin sans passer par le reverse proxy

    Cas où un service d'administration (Portainer, dashboard interne, Home Assistant...) ne doit être joignable **que via le tunnel WireGuard**, jamais via Internet public, même pas via le reverse proxy.

    services:
      wireguard:
        image: linuxserver/wireguard
        network_mode: host   # nécessaire pour le routing VPN natif
        cap_add:
          - NET_ADMIN
          - SYS_MODULE
        volumes:
          - ./wg-config:/config
    
      admin-panel:
        image: mon-admin-panel
        ports:
          - "10.10.10.1:9000:9000"   # IP wireguard + bind UNIQUEMENT sur l'IP de l'interface wg0
        networks:
          - backend

    Complément côté firewall (le bind seul limite déjà l'exposition, mais on verrouille aussi explicitement) :

    # Autoriser uniquement depuis le subnet WireGuard
    sudo iptables -I DOCKER-USER -i wg0 -s 10.10.10.0/24 -j ACCEPT
    sudo iptables -I DOCKER-USER -p tcp --dport 9000 -j DROP
    5.4 Debug local uniquement

    Pattern générique pour tout service qu'on veut inspecter temporairement sans l'exposer :

        ports:
          - "127.0.0.1:PORT:PORT"

    Accessible uniquement en `curl localhost:PORT` depuis le host lui-même — jamais depuis le LAN, le WAN ou le VPN.