Chaîne complète : CI → GHCR privé → Watchtower → Prod

Mettre en place un flux de déploiement continu fiable avec GitHub Actions, GHCR privé et Watchtower.

Série Watchtower + GHCR en production #3
Written by AI
#Docker #Production #CI/CD #Watchtower #GHCR #Symfony

1. Build & Push d’image avec GitHub Actions

Workflow minimal .github/workflows/build.yml :

name: Build and Push (GHCR)
on:
  push:
    branches: [ "main" ]
  workflow_dispatch:
    
jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ghcr.io/<user>/<repo>:main
  • L’action pousse main à chaque commit sur main.
  • Ton image reste privée tant que tu ne changes pas sa visibilité.

2. Infra Watchtower (Compose)

infra/compose.yaml :

services:
  watchtower:
    image: containrrr/watchtower
    command: --interval 300 --cleanup --label-enable
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /home/ubuntu/.docker/config.json:/config.json:ro
    restart: unless-stopped

3. Service applicatif (ex. blog Symfony)

blog/compose.prod.yaml :

services:
  blog:
    image: ghcr.io/<user>/<repo>:main
    restart: unless-stopped
    networks: [edge]
    expose: ["8080"]             # aligne avec ton app (ex. FrankenPHP :8080)
    env_file:
      - .env.prod                # secrets côté serveur, hors Git
    labels:
      com.centurylinklabs.watchtower.enable: "true"
      caddy: blog.code-ludus.com
      caddy.encode: zstd gzip
      caddy.reverse_proxy: "{{upstreams 8080}}"

networks:
  edge:
    external: true

Contenu typique de .env.prod (sur le serveur, non commité)

APP_ENV=prod
APP_DEBUG=0
APP_SECRET=change-me-please-32+chars
TRUSTED_PROXIES=172.16.0.0/12
TRUSTED_HOSTS=^blog\.code-ludus\.com$

Conseil Symfony : garde un .env non sensible dans l’image (même vide) pour éviter l’exception Unable to read the "/app/.env", et surcharge tout en prod via .env.prod.

4. Reverse proxy (Caddy Docker Proxy)

infra/compose.yaml (extrait) :

services:
  caddy:
    image: lucaslorentz/caddy-docker-proxy:2.8-alpine
    ports: ["80:80","443:443"]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - caddy_data:/data
      - caddy_config:/config
    environment:
      CADDY_INGRESS_NETWORKS: edge
      ACME_EMAIL: <email@exemple.com>
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:

Dès que ton service blog porte des labels Caddy et rejoint le réseau edge, Caddy publie automatiquement https://blog.code-ludus.com (certificat Let’s Encrypt inclus).

5. Déroulé “de bout en bout”

  1. git push main
  2. GitHub Actions build → push ghcr.io/<user>/<repo>:main
  3. Watchtower (toutes les 5 min) détecte un digest nouveau
  4. Watchtower pull → stop/remove/create du conteneur blog
  5. Caddy reverse proxy vers le nouveau conteneur

6. Dépannage rapide

  • GHCR privé : monte /config.json dans Watchtower, pas ~/.docker/config.json.
  • Port 502 via Caddy : vérifie que le label caddy.reverse_proxy vise le bon port ({{upstreams 8080}} vs 8000).
  • Erreur Symfony .env : inclure un .env non sensible dans l’image, secrets via .env.prod.
  • Labels Caddy invalides : éviter handle_path mal formé ; préférer un respond avec matcher :
labels:
    caddy.@health.path: /healthz
    caddy.respond.@health: '"ok" 200'
  • Watchtower ne voit pas le service : activer --label-enable + label com.centurylinklabs.watchtower.enable: "true" sur le service cible.