# Déployer avec Compose, Traefik et Let's Encrypt

On installe Traefik en reverse proxy avec Docker Compose, des certificats Let's Encrypt renouvelés tout seuls, puis une application et sa base de données.

Source : https://simotech.dev/blog/deployer-application-docker-compose-traefik-letsencrypt

Dans mon [article précédent](/blog/deployer-infrastructure-scaleway-terraform), on a déployé une infrastructure Scaleway avec Terraform. Le serveur tourne, Docker et Docker Compose sont installés, mais il n'y a encore rien dessus. On va y mettre une vraie application, derrière Traefik, avec des certificats SSL qui se renouvellent tout seuls grâce à Let's Encrypt.

C'est une stack que j'aime bien parce qu'elle est simple à tenir dans la tête, et les certificats ne sont plus un sujet. L'article convient à n'importe quel serveur Linux avec Docker. La dernière partie, elle, reprend le Terraform de la série pour que tout s'installe au démarrage de l'instance.

## Ce qu'on va construire

À la fin de cet article, tu auras :

- Traefik v3 configuré comme reverse proxy
- Let's Encrypt pour des certificats SSL automatiques et gratuits
- Un Docker Socket Proxy pour sécuriser l'accès au Docker socket
- Des middlewares de sécurité (headers, redirection HTTPS)
- Une application exemple exposée en HTTPS
- Le dashboard Traefik protégé par une authentification

```mermaid
flowchart LR
    U["Navigateur"] -->|"ports 80 et 443"| T["Traefik"]
    T -->|"réseau proxy"| W["whoami"]
    T -->|"réseau proxy"| A["App Node.js"]
    A -->|"réseau internal"| D[("PostgreSQL")]
    T -.->|"lecture seule"| SP["Socket proxy"]
    SP -.-> DS["Docker socket"]
    T -.->|"certificats"| LE["Let's Encrypt"]
```

## Prérequis

- Un serveur avec Docker et la commande `docker compose`
- Un nom de domaine dont tu peux modifier le DNS, avec des enregistrements `A` pointant vers l'IP du serveur pour `traefik`, `whoami` et `app`
- Les ports 80 et 443 ouverts dans ton firewall

Les commandes supposent que tu es connecté en `root`, comme sur l'instance de l'article précédent. Sinon, ajoute `sudo`.

## Pourquoi Traefik plutôt que Nginx

Nginx avec certbot, ça marche. Mais chaque nouveau service demande un fichier de conf, un reload, et un cron pour les certificats. Avec Traefik, tout ça disparaît.

Un **reverse proxy** reçoit toutes les requêtes HTTP du serveur et les redirige vers le bon container selon le nom de domaine. Traefik regarde les containers Docker qui tournent, lit leurs labels, et crée les routes tout seul. Il demande les certificats à Let's Encrypt et les renouvelle sans que tu t'en occupes. Pas besoin de le redémarrer pour ajouter un service. Et il a un dashboard pour voir ce qui est routé où, ce qui aide pas mal quand quelque chose ne répond pas.

## Architecture des fichiers

```
/opt/docker/
├── traefik/
│   ├── docker-compose.yml     # Stack Traefik
│   ├── acme.json              # Certificats Let's Encrypt (généré)
│   └── dynamic/
│       └── middlewares.yml    # Middlewares réutilisables
└── apps/
    └── whoami/
        └── docker-compose.yml # Application exemple
```

On sépare Traefik des applications. Comme ça, tu peux redémarrer, casser ou supprimer une app sans jamais toucher au reverse proxy. Les autres services restent en ligne.

Crée les dossiers :

```bash
mkdir -p /opt/docker/traefik/dynamic /opt/docker/apps/whoami
```

## Étape 1 : Créer le réseau Docker

Traefik et les applications doivent partager un réseau Docker pour communiquer :

```bash
docker network create proxy
```

Ce réseau `proxy` sera utilisé par tous les services exposés publiquement.

## Étape 2 : Configurer Traefik

### docker-compose.yml

Crée le fichier `/opt/docker/traefik/docker-compose.yml` :

```yaml title="/opt/docker/traefik/docker-compose.yml"
services:
    # Proxy sécurisé pour le socket Docker
    socket-proxy:
        image: tecnativa/docker-socket-proxy:v0.5.0
        restart: unless-stopped
        environment:
            CONTAINERS: 1
            NETWORKS: 1
            SERVICES: 0
            TASKS: 0
            POST: 0
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock:ro
        networks:
            - socket-proxy

    traefik:
        image: traefik:v3.7
        container_name: traefik
        restart: unless-stopped
        depends_on:
            - socket-proxy
        security_opt:
            - no-new-privileges:true
        command:
            # API et Dashboard
            - "--api.dashboard=true"

            # Provider Docker via socket proxy
            - "--providers.docker=true"
            - "--providers.docker.endpoint=tcp://socket-proxy:2375"
            - "--providers.docker.exposedbydefault=false"
            - "--providers.docker.network=proxy"

            # Provider fichiers pour middlewares
            - "--providers.file.directory=/etc/traefik/dynamic"
            - "--providers.file.watch=true"

            # Entrypoints
            - "--entrypoints.web.address=:80"
            - "--entrypoints.websecure.address=:443"

            # Redirection HTTP -> HTTPS globale
            - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
            - "--entrypoints.web.http.redirections.entrypoint.scheme=https"

            # Let's Encrypt
            - "--certificatesresolvers.letsencrypt.acme.email=ton@email.com"
            - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
            - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"

            # Logs
            - "--log.level=INFO"
        ports:
            - "80:80"
            - "443:443"
        volumes:
            - ./acme.json:/letsencrypt/acme.json
            - ./dynamic:/etc/traefik/dynamic:ro
        networks:
            - proxy
            - socket-proxy
        labels:
            # Dashboard Traefik
            - "traefik.enable=true"
            - "traefik.http.routers.traefik.rule=Host(`traefik.ton-domaine.com`)"
            - "traefik.http.routers.traefik.entrypoints=websecure"
            - "traefik.http.routers.traefik.tls=true"
            - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
            - "traefik.http.routers.traefik.service=api@internal"
            - "traefik.http.routers.traefik.middlewares=auth@file,security-headers@file"

networks:
    proxy:
        external: true
    socket-proxy:
        driver: bridge
```

Remplace `ton@email.com` par ton adresse : si Let's Encrypt la rejette, il ne crée pas de compte et aucun certificat n'est émis.

### Ce qui mérite une explication

Le socket proxy, d'abord. La plupart des tutos montent `/var/run/docker.sock` directement dans Traefik. Le problème, c'est que ce socket donne un accès root à la machine : un container qui le lit peut en lancer d'autres, avec les privilèges qu'il veut. Le proxy ne laisse passer que la lecture des containers et des réseaux, ce dont Traefik a besoin, et rien d'autre.

Ensuite `exposedbydefault=false`. Sans cette option, Traefik expose tous les containers qu'il voit. Avec, il ne s'occupe que de ceux qui portent le label `traefik.enable=true`, donc rien ne sort sans que tu l'aies décidé.

Enfin la redirection HTTPS. Elle est configurée une fois, sur l'entrypoint `web`, et elle s'applique à tout. Tu n'as pas à la répéter sur chaque service.

### Préparer le fichier acme.json

Traefik stocke dans ce fichier les certificats obtenus auprès de Let's Encrypt. Il refuse de s'en servir si les permissions sont trop larges, donc on le crée avant et on le verrouille :

```bash
touch /opt/docker/traefik/acme.json
chmod 600 /opt/docker/traefik/acme.json
```

Si le fichier n'existe pas au premier `docker compose up`, Docker crée un dossier à sa place pour honorer le montage, et Traefik ne peut rien y écrire.

## Étape 3 : Configurer les middlewares

Un **middleware**, c'est un traitement appliqué à la requête avant qu'elle n'arrive à ton service : ajouter des headers, demander un mot de passe, limiter le débit. On les définit une fois dans un fichier, et chaque service y fait référence par son nom. Crée le fichier `/opt/docker/traefik/dynamic/middlewares.yml` :

```yaml title="/opt/docker/traefik/dynamic/middlewares.yml"
http:
    middlewares:
        # Headers de sécurité
        security-headers:
            headers:
                contentTypeNosniff: true
                frameDeny: true
                stsSeconds: 31536000
                stsIncludeSubdomains: true
                referrerPolicy: "strict-origin-when-cross-origin"

        # Authentification basique pour le dashboard
        auth:
            basicAuth:
                users:
                    - "admin:$2y$05$your_hashed_password_here"
```

Dans l'ordre : `contentTypeNosniff` interdit au navigateur de deviner le type d'un fichier, `frameDeny` empêche d'afficher tes pages dans une iframe, les deux lignes `sts` activent HSTS, et `referrerPolicy` limite ce que le navigateur transmet aux autres sites quand on clique sur un lien.

> [!WARNING]
> HSTS dit au navigateur de ne plus jamais contacter ce domaine en HTTP pendant la durée indiquée, ici un an. Avec `stsIncludeSubdomains`, ça vaut aussi pour tous ses sous-domaines. Si l'un d'eux doit rester en HTTP, retire cette ligne.

### Générer le mot de passe hashé

Pour l'authentification du dashboard, génère un hash avec htpasswd :

```bash
# Installer htpasswd si nécessaire
apt-get install -y apache2-utils

# Générer le hash pour l'utilisateur 'admin'
htpasswd -nB admin
```

La commande te demande un mot de passe et affiche quelque chose comme :

```text
admin:$2y$05$LqKVSNp...
```

Copie cette ligne dans le fichier `middlewares.yml`.

> [!WARNING]
> Dans ce fichier YAML, tu laisses les `$` tels quels. C'est uniquement dans les labels d'un docker-compose qu'il faut les doubler (`$$`), sinon Compose les prend pour des variables d'environnement. Le symptôme quand on se trompe : un dashboard qui refuse tous les mots de passe, sans aucune erreur dans les logs.

## Étape 4 : Démarrer Traefik

```bash
cd /opt/docker/traefik
docker compose up -d
```

Vérifie les logs :

```bash
docker compose logs -f traefik
```

Tu devrais voir Traefik démarrer et se connecter au socket proxy. S'il se plaint du `acme.json`, c'est presque toujours les permissions.

## Étape 5 : Déployer une application

Pour tester, je prends toujours `whoami`, un petit service qui renvoie les infos de la requête qu'il reçoit. Pas de config, pas de base de données, idéal pour vérifier que le routage et le SSL fonctionnent.

Crée `/opt/docker/apps/whoami/docker-compose.yml` :

```yaml title="/opt/docker/apps/whoami/docker-compose.yml"
services:
    whoami:
        image: traefik/whoami:v1.11
        container_name: whoami
        restart: unless-stopped
        networks:
            - proxy
        labels:
            - "traefik.enable=true"

            # Router HTTPS
            - "traefik.http.routers.whoami.rule=Host(`whoami.ton-domaine.com`)"
            - "traefik.http.routers.whoami.entrypoints=websecure"
            - "traefik.http.routers.whoami.tls=true"
            - "traefik.http.routers.whoami.tls.certresolver=letsencrypt"
            - "traefik.http.routers.whoami.middlewares=security-headers@file"

networks:
    proxy:
        external: true
```

Démarre l'application :

```bash
cd /opt/docker/apps/whoami
docker compose up -d
```

### Ce qui se passe

1. Traefik détecte le nouveau container via le socket proxy
2. Il lit les labels et crée automatiquement une route
3. Il demande un certificat à Let's Encrypt via le HTTP-01 challenge
4. Le service est accessible en HTTPS avec un certificat valide

Le **HTTP-01 challenge**, c'est la façon dont Let's Encrypt vérifie que le domaine est bien à toi : il appelle une URL précise sur le port 80 de ton domaine, et Traefik y répond à ta place.

Visite `https://whoami.ton-domaine.com` pour vérifier. Le certificat peut mettre un moment à arriver, le temps que Let's Encrypt valide le challenge. Si tu rafraîchis trop vite, tu verras le certificat auto-signé par défaut de Traefik. Attends un peu avant de chercher un problème.

## Un cas plus réaliste : une app et sa base de données

`whoami` c'est bien, mais tes vraies apps ont une base de données. Voici comment je structure ça avec une application Node.js et PostgreSQL. Pour que l'exemple tourne tel quel, l'application tient en un fichier :

```js title="/opt/docker/apps/myapp/src/server.js"
const http = require("node:http");

http.createServer((req, res) => {
    if (req.url === "/health") {
        res.writeHead(200).end("ok");
        return;
    }
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Hello depuis myapp\n");
}).listen(3000);
```

Et le fichier Compose à côté :

```yaml title="/opt/docker/apps/myapp/docker-compose.yml"
services:
    app:
        image: node:24-alpine
        container_name: myapp
        restart: unless-stopped
        working_dir: /app
        volumes:
            - ./src:/app
        command: node server.js
        environment:
            - DATABASE_URL=postgresql://user:password@db:5432/myapp
        networks:
            - proxy
            - internal
        labels:
            - "traefik.enable=true"
            - "traefik.http.routers.myapp.rule=Host(`app.ton-domaine.com`)"
            - "traefik.http.routers.myapp.entrypoints=websecure"
            - "traefik.http.routers.myapp.tls=true"
            - "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
            - "traefik.http.routers.myapp.middlewares=security-headers@file"
            - "traefik.http.services.myapp.loadbalancer.server.port=3000"

    db:
        image: postgres:18-alpine
        container_name: myapp-db
        restart: unless-stopped
        environment:
            - POSTGRES_USER=user
            - POSTGRES_PASSWORD=password
            - POSTGRES_DB=myapp
        volumes:
            - db-data:/var/lib/postgresql
        networks:
            - internal

networks:
    proxy:
        external: true
    internal:
        driver: bridge

volumes:
    db-data:
```

L'app est sur deux réseaux, `proxy` pour parler à Traefik et `internal` pour parler à la base. La base, elle, n'est que sur `internal`. Elle n'a aucun moyen d'être jointe depuis l'extérieur, même si tu oublies un firewall.

Le label `loadbalancer.server.port` dit à Traefik sur quel port l'app écoute dans le container. Il devient obligatoire quand l'image ne déclare aucun port, ce qui est le cas de `node`, ou qu'elle en déclare plusieurs.

> [!NOTE]
> Le volume est monté sur `/var/lib/postgresql`, et non sur `/var/lib/postgresql/data` comme on le voit dans beaucoup d'exemples. L'image officielle a changé de chemin avec PostgreSQL 18, et refuse de démarrer avec l'ancien.

Le mot de passe en clair dans le fichier est là pour l'exemple. Sur un vrai projet, sors-le dans un fichier `.env` à côté du `docker-compose.yml`, que tu ne commites pas.

## Tester avec le staging de Let's Encrypt

Let's Encrypt a des [limites de rate](https://letsencrypt.org/docs/rate-limits/) strictes. On les atteint vite quand la validation échoue en boucle parce que le DNS n'est pas encore en place, ou quand on repart d'un `acme.json` vide à chaque essai. Pendant tes tests, utilise le serveur staging :

```yaml
command:
    # ... autres options ...
    - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
```

Les certificats staging ne sont pas valides, ton navigateur affichera un avertissement, mais tu peux relancer autant de fois que tu veux.

Une fois tes tests terminés, supprime la ligne `--certificatesresolvers.letsencrypt.acme.caserver=...` de ton docker-compose.yml et vide le `acme.json` (sinon Traefik garde les certificats staging en cache). Sans cette option, Traefik repasse sur le serveur de production et génère des certificats valides.

## Dépannage

### Vérifier les routes actives

Accède au dashboard Traefik via `https://traefik.ton-domaine.com` (protégé par authentification).

Tu peux aussi inspecter les logs :

```bash
# Logs Traefik
docker compose logs -f traefik

# Logs du socket proxy
docker compose logs -f socket-proxy
```

### Les trois problèmes qui reviennent tout le temps

Le certificat n'arrive pas. Dans l'ordre : le port 80 est-il vraiment ouvert depuis Internet (pas seulement dans le security group, aussi dans un éventuel firewall de l'OS) ? Le DNS pointe-t-il bien vers ce serveur ? Et que disent les logs avec `docker compose logs traefik` ? Le HTTP-01 challenge passe par le port 80, si Let's Encrypt ne peut pas y accéder, rien ne se passe.

Le service n'apparaît pas dans le dashboard. Soit le container n'est pas sur le réseau `proxy`, soit il manque le label `traefik.enable=true`. Regarde aussi les logs du socket-proxy, il refuse parfois des requêtes si les permissions sont trop restrictives.

Erreur 502 Bad Gateway. Traefik a trouvé le service mais n'arrive pas à lui parler. Le container n'est pas démarré, ou le port dans `loadbalancer.server.port` ne correspond pas à celui sur lequel l'app écoute réellement. C'est de loin le cas le plus fréquent.

## Quelques habitudes qui m'évitent des ennuis

### 1. Un réseau par contexte

```yaml
networks:
    proxy: # Pour les services exposés
        external: true
    internal: # Pour la communication interne
        driver: bridge
```

### 2. Toujours utiliser security_opt

```yaml
security_opt:
    - no-new-privileges:true
```

Empêche un processus dans le container de gagner des privilèges via un binaire setuid. Ça ne coûte rien et ça ferme une porte.

### 3. Labels explicites

Nomme tes routers d'après le service, pas `router1`. Dans six mois, dans le dashboard, tu te remercieras :

```yaml
labels:
    - "traefik.http.routers.myapp.rule=..." # Nom explicite
    - "traefik.http.routers.myapp.middlewares=security-headers@file"
```

### 4. Healthchecks

Sans healthcheck, Docker considère qu'un container qui tourne est un container qui marche. Avec, Traefik attend que l'app réponde vraiment avant de lui envoyer du trafic :

```yaml
services:
    app:
        healthcheck:
            test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/health"]
            interval: 30s
            timeout: 10s
            retries: 3
```

J'utilise `wget` parce que les images Alpine n'embarquent pas `curl`. Tant que le premier contrôle n'est pas passé, le container est en `starting` et Traefik répond 404 sur son domaine. Avec un intervalle de 30 secondes, c'est le délai à attendre après chaque redémarrage.

### 5. Limiter les ressources

Sur un petit serveur, une app qui part en fuite mémoire peut tout emporter avec elle, Traefik compris. Une limite par service évite ça :

```yaml
services:
    app:
        deploy:
            resources:
                limits:
                    cpus: "0.5"
                    memory: 512M
```

## Tout installer au démarrage avec Terraform

Si tu as suivi mon [article sur Scaleway avec Terraform](/blog/deployer-infrastructure-scaleway-terraform), tout ce qu'on vient de faire à la main peut partir dans le cloud-init. L'instance sort de terre avec Traefik déjà lancé. C'est comme ça que je fais maintenant : je ne me connecte plus en SSH pour installer quoi que ce soit.

### Les variables

Ajoute ces variables dans ton `variables.tf` :

```terraform title="variables.tf"
variable "domain" {
  description = "Nom de domaine principal"
  type        = string
}

variable "email" {
  description = "Email pour Let's Encrypt"
  type        = string
}

variable "traefik_password" {
  description = "Mot de passe pour le dashboard Traefik"
  type        = string
  sensitive   = true
}
```

Leurs valeurs vont dans `terraform.tfvars`, avec les credentials Scaleway.

### Le hash du mot de passe

Tu ne vas pas lancer `htpasswd` à la main et coller le résultat dans un template. Le provider `htpasswd` fait ça à partir d'une variable sensible. On le déclare dans `versions.tf`, à côté des autres :

```terraform title="versions.tf"
terraform {
  required_providers {
    # ... autres providers ...
    htpasswd = {
      source  = "loafoe/htpasswd"
      version = "~> 2.0"
    }
  }
}
```

Puis dans `main.tf`, on génère le hash et on le passe au template, avec le domaine et l'email. Ce bloc `locals` remplace celui de l'article précédent :

```terraform title="main.tf"
resource "htpasswd_password" "traefik" {
  password = var.traefik_password
}

locals {
  cloud_init_content = templatefile("${path.module}/templates/cloud-init.yml", {
    domain       = var.domain
    email        = var.email
    traefik_auth = "admin:${htpasswd_password.traefik.bcrypt}"
  })
}
```

Relance `terraform init` pour télécharger le nouveau provider.

### Le cloud-init complet

Remplace le contenu de `templates/cloud-init.yml` par cette version. Elle installe Docker, écrit les deux fichiers de configuration de Traefik avec `write_files`, puis le démarre :

```yaml title="templates/cloud-init.yml"
#cloud-config
package_update: true
package_upgrade: true

packages:
    - ca-certificates
    - curl
    - git
    - htop
    - docker.io
    - docker-compose-v2

write_files:
    - path: /opt/docker/traefik/docker-compose.yml
      permissions: "0644"
      content: |
          services:
              socket-proxy:
                  image: tecnativa/docker-socket-proxy:v0.5.0
                  restart: unless-stopped
                  environment:
                      CONTAINERS: 1
                      NETWORKS: 1
                      SERVICES: 0
                      TASKS: 0
                      POST: 0
                  volumes:
                      - /var/run/docker.sock:/var/run/docker.sock:ro
                  networks:
                      - socket-proxy

              traefik:
                  image: traefik:v3.7
                  container_name: traefik
                  restart: unless-stopped
                  depends_on:
                      - socket-proxy
                  security_opt:
                      - no-new-privileges:true
                  command:
                      - "--api.dashboard=true"
                      - "--providers.docker=true"
                      - "--providers.docker.endpoint=tcp://socket-proxy:2375"
                      - "--providers.docker.exposedbydefault=false"
                      - "--providers.docker.network=proxy"
                      - "--providers.file.directory=/etc/traefik/dynamic"
                      - "--providers.file.watch=true"
                      - "--entrypoints.web.address=:80"
                      - "--entrypoints.websecure.address=:443"
                      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
                      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
                      - "--certificatesresolvers.letsencrypt.acme.email=${email}"
                      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
                      - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
                      - "--log.level=INFO"
                  ports:
                      - "80:80"
                      - "443:443"
                  volumes:
                      - ./acme.json:/letsencrypt/acme.json
                      - ./dynamic:/etc/traefik/dynamic:ro
                  networks:
                      - proxy
                      - socket-proxy
                  labels:
                      - "traefik.enable=true"
                      - "traefik.http.routers.traefik.rule=Host(`traefik.${domain}`)"
                      - "traefik.http.routers.traefik.entrypoints=websecure"
                      - "traefik.http.routers.traefik.tls=true"
                      - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
                      - "traefik.http.routers.traefik.service=api@internal"
                      - "traefik.http.routers.traefik.middlewares=auth@file,security-headers@file"

          networks:
              proxy:
                  external: true
              socket-proxy:
                  driver: bridge

    - path: /opt/docker/traefik/dynamic/middlewares.yml
      permissions: "0644"
      content: |
          http:
              middlewares:
                  security-headers:
                      headers:
                          contentTypeNosniff: true
                          frameDeny: true
                          stsSeconds: 31536000
                          stsIncludeSubdomains: true
                          referrerPolicy: "strict-origin-when-cross-origin"
                  auth:
                      basicAuth:
                          users:
                              - "${traefik_auth}"

runcmd:
    # Activer Docker et attendre qu'il réponde
    - systemctl enable --now docker
    - until docker info > /dev/null 2>&1; do sleep 1; done

    # Créer la structure
    - mkdir -p /opt/docker/apps
    - touch /opt/docker/traefik/acme.json
    - chmod 600 /opt/docker/traefik/acme.json

    # Créer le réseau partagé et démarrer Traefik
    - docker network create proxy
    - cd /opt/docker/traefik && docker compose up -d
```

Deux détails. Les `${email}`, `${domain}` et `${traefik_auth}` sont remplacés par `templatefile()` avant que le fichier parte sur le serveur. Et la boucle `until docker info` attend que Docker réponde : il met parfois quelques secondes à être prêt après son démarrage, et sans attente le `docker compose up` échouait une fois sur deux chez moi.

> [!WARNING]
> `templatefile()` interprète tous les `${...}` du fichier. Si tu ajoutes un fichier Compose qui utilise ses propres variables, par exemple `${POSTGRES_PASSWORD}`, écris `$${POSTGRES_PASSWORD}` dans le template pour que Terraform le laisse passer.

### Le résultat

Le contenu du cloud-init a changé, donc `terraform apply` détruit l'instance et la recrée, comme prévu par le `replace_triggered_by` de l'article précédent. L'IP, elle, ne bouge pas. Quelques minutes plus tard, l'instance tourne avec :

- Docker et Docker Compose installés
- La structure de fichiers créée
- Le réseau `proxy` configuré
- Traefik lancé et prêt à demander des certificats

Il ne te reste plus qu'à pointer ton DNS vers l'IP de l'instance et déployer tes applications. Si Traefik a démarré avant que le DNS soit en place, le certificat du dashboard n'a pas pu être émis : relance-le avec `docker compose restart traefik` une fois l'enregistrement propagé.

## Pour aller plus loin

- [Documentation officielle Traefik](https://doc.traefik.io/traefik/)
- [ACME / Let's Encrypt](https://doc.traefik.io/traefik/https/acme/)
- [Middlewares disponibles](https://doc.traefik.io/traefik/middlewares/overview/)
- [Docker Socket Proxy](https://github.com/Tecnativa/docker-socket-proxy)

Tu as maintenant un serveur sur lequel déployer un nouveau service se résume à écrire un docker-compose avec quelques labels. Quand j'ai monté cette stack, c'est ce que je voulais : ne plus penser au reverse proxy ni au SSL. Et comme tout part du cloud-init, le serveur se recrée toujours en une commande, Traefik compris.
