Déploiement : CI/CD GitLab + image frontend de prod + Caddy

Remplace la chaîne GitHub Actions (jamais réellement branchée : aucun
remote git n'existait) par une chaîne GitLab CI/CD, et comble un manque
qu'elle avait déjà : le frontend n'était jusqu'ici jamais construit ni
déployé en production.

- `.gitlab-ci.yml` : tests (backend + frontend) à chaque push ; sur `main`,
  build des images backend et frontend (registre de conteneurs GitLab) puis
  déploiement SSH sur le VPS (pull, migration Alembic, up -d).
- `frontend/Dockerfile.prod` + `frontend/nginx.conf` : build Vite statique
  servi par nginx (repli SPA sur index.html). `VITE_API_MODE`/
  `VITE_EDITION_ID` sont des variables de build, passées en `--build-arg`
  depuis la variable CI/CD `VITE_EDITION_ID`.
- `deploy/docker-compose.prod.yml` : ajout du service `frontend` ; `backend`
  et `frontend` ne publient plus que sur `127.0.0.1` (Caddy, déjà installé
  sur le serveur, devient l'unique porte d'entrée publique).
- `deploy/Caddyfile.benevoles` : bloc de vhost à ajouter au Caddy existant
  (`/api/*` vers le backend avec préfixe retiré, le reste vers le frontend).
- `deploy/README.md` : runbook complet (remote GitLab, variables CI/CD,
  préparation du VPS, création de l'édition de production avant le premier
  build frontend, vhost Caddy).
- CLAUDE.md et README.md mis à jour en conséquence.

Vérifié : `docker compose -f deploy/docker-compose.prod.yml config`,
build réel de l'image frontend de prod, / et une route SPA (/grille)
répondent 200 via nginx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-14 19:04:28 +02:00
co-authored by Claude Sonnet 5
parent 367dfe42eb
commit d5f67b2701
9 changed files with 333 additions and 135 deletions
-101
View File
@@ -1,101 +0,0 @@
name: Déploiement production
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: benevoles
POSTGRES_PASSWORD: benevoles
POSTGRES_DB: benevoles_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U benevoles"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
TEST_DATABASE_URL: postgresql+psycopg://benevoles:benevoles@localhost:5432/benevoles_test
DATABASE_URL: postgresql+psycopg://benevoles:benevoles@localhost:5432/benevoles_test
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
version: "0.5.11"
- name: Installer les dépendances
working-directory: backend
run: uv sync
- name: Lint
working-directory: backend
run: uv run ruff check .
- name: Tests
working-directory: backend
run: uv run pytest
build:
needs: test
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 }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}-backend
tags: |
type=raw,value=latest
type=sha,prefix=sha-
- uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Copier la configuration de déploiement
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
source: deploy/docker-compose.prod.yml
target: ${{ secrets.DEPLOY_PATH }}
strip_components: 1
- name: Redéployer
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
set -e
cd "${{ secrets.DEPLOY_PATH }}"
export BACKEND_IMAGE="ghcr.io/${{ github.repository }}-backend:sha-${{ github.sha }}"
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
+116
View File
@@ -0,0 +1,116 @@
# CI/CD : tests à chaque push, puis sur `main` seulement, build des images
# (registre de conteneurs GitLab) et déploiement SSH sur le VPS.
#
# Variables CI/CD à définir (Paramètres → CI/CD → Variables du projet) :
# SSH_HOST hôte du VPS
# SSH_USER utilisateur SSH
# SSH_KEY clé privée SSH (format OpenSSH), variable « protégée »
# + « masquée », type fichier ou variable multi-lignes
# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles)
# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS la
# toute première mise en service (voir deploy/README.md) —
# le frontend fige cette valeur au moment du build.
#
# `CI_REGISTRY`, `CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD` et
# `CI_REGISTRY_IMAGE` sont fournis automatiquement par GitLab.
stages:
- test
- build
- deploy
variables:
BACKEND_IMAGE: $CI_REGISTRY_IMAGE/backend
FRONTEND_IMAGE: $CI_REGISTRY_IMAGE/frontend
backend-test:
stage: test
image: python:3.12-slim
services:
- name: postgres:16-alpine
alias: postgres
variables:
POSTGRES_USER: benevoles
POSTGRES_PASSWORD: benevoles
POSTGRES_DB: benevoles_test
TEST_DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
before_script:
- pip install --no-cache-dir uv
- cd backend
- uv sync
script:
- uv run ruff check .
- uv run alembic upgrade head
- uv run alembic check
- uv run pytest -q
frontend-test:
stage: test
image: node:20-alpine
before_script:
- cd frontend
- npm ci
script:
- npm run typecheck
- npm run lint
- npm run build
- npm run smoke
build-backend:
stage: build
image: docker:27
services:
- docker:27-dind
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$BACKEND_IMAGE:latest" backend
- docker push "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker push "$BACKEND_IMAGE:latest"
build-frontend:
stage: build
image: docker:27
services:
- docker:27-dind
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- >
docker build
--build-arg VITE_API_MODE=http
--build-arg VITE_EDITION_ID="$VITE_EDITION_ID"
-f frontend/Dockerfile.prod
-t "$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$FRONTEND_IMAGE:latest"
frontend
- docker push "$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker push "$FRONTEND_IMAGE:latest"
deploy:
stage: deploy
image: alpine:3.20
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
before_script:
- apk add --no-cache openssh-client rsync
- eval "$(ssh-agent -s)"
- echo "$SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
script:
- rsync -az deploy/docker-compose.prod.yml "$SSH_USER@$SSH_HOST:$DEPLOY_PATH/docker-compose.prod.yml"
- |
ssh "$SSH_USER@$SSH_HOST" sh -s <<EOF
set -e
cd "$DEPLOY_PATH"
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
export BACKEND_IMAGE="$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA"
export FRONTEND_IMAGE="$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA"
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml run --rm backend uv run alembic upgrade head
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
EOF
+21 -9
View File
@@ -335,10 +335,11 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
├── README.md
├── docker-compose.yml # dev : db + backend (hot reload)
├── .env.example
├── .github/workflows/deploy.yml # CI/CD : build → GHCR → SSH sur le VPS
├── .gitlab-ci.yml # CI/CD : tests → build (registre GitLab) → SSH sur le VPS
├── deploy/
│ ├── docker-compose.prod.yml
│ └── README.md # secrets requis, setup VPS
│ ├── docker-compose.prod.yml # db + backend + frontend, ports en 127.0.0.1
│ ├── Caddyfile.benevoles # bloc à ajouter au Caddy du VPS (reverse proxy)
│ └── README.md # variables CI/CD requises, mise en service du VPS
├── docker/postgres-init/ # création de la base de test au 1er boot
├── backend/
│ ├── pyproject.toml # géré par uv
@@ -417,15 +418,26 @@ Aucune dépendance locale requise hors Docker.
## 9. Déploiement
- Dépôt GitHub. VPS avec Docker.
- `.github/workflows/deploy.yml` : sur chaque push `main` → build de l'image
backend → push sur `ghcr.io/<owner>/<repo>-backend` → SSH sur le VPS →
`docker compose -f docker-compose.prod.yml pull && up -d`.
- Secrets requis (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`,
`SSH_KEY`, `DEPLOY_PATH`, `GHCR_TOKEN`.
- Dépôt GitLab. VPS avec Docker + un Caddy déjà installé en service système
(hors de ce dépôt), qui reçoit tout le trafic public en HTTPS et fait
reverse proxy vers les conteneurs.
- `.gitlab-ci.yml` : à chaque push, tests (backend + frontend) ; sur `main`
seulement, build des images `backend` et `frontend` (registre de conteneurs
du projet GitLab) puis SSH sur le VPS → copie de
`deploy/docker-compose.prod.yml` → `pull` → migration Alembic → `up -d`.
- Le frontend est un **build statique** (`frontend/Dockerfile.prod`, servi par
nginx) : `VITE_API_MODE`/`VITE_EDITION_ID` sont figées au moment du build,
pas à l'exécution — l'édition de production doit donc exister avant la
première construction de cette image (voir `deploy/README.md`).
- Variables CI/CD requises (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`,
`SSH_KEY`, `DEPLOY_PATH`, `VITE_EDITION_ID`.
- Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`,
`POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les
variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`).
- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir
`docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à
ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers
le backend avec le préfixe retiré, le reste vers le frontend).
---
+2 -2
View File
@@ -47,5 +47,5 @@ démarrage du conteneur PostgreSQL.
|--------------|-------------------------------------------------|
| `backend/` | API FastAPI, modèle SQLModel, migrations Alembic |
| `frontend/` | Interface React + Vite + TS + Tailwind (service `frontend`) |
| `deploy/` | Configuration de déploiement production |
| `.github/` | Chaîne CI/CD (build → GHCR → SSH sur le VPS) |
| `deploy/` | Configuration de déploiement production (compose, Caddy, runbook) |
| `.gitlab-ci.yml` | Chaîne CI/CD (tests → build → SSH sur le VPS) |
+24
View File
@@ -0,0 +1,24 @@
# Bloc à ajouter au Caddyfile existant du serveur (ex. /etc/caddy/Caddyfile,
# ou un fichier importé par lui — voir deploy/README.md). Remplacer le nom de
# domaine par le vôtre. Caddy gère seul le certificat HTTPS (Let's Encrypt).
#
# Le frontend (nginx, port 8080) et le backend (port 8000) écoutent tous les
# deux sur 127.0.0.1 uniquement (voir docker-compose.prod.yml) : ce vhost est
# leur unique porte d'entrée publique.
benevoles.mon-domaine.fr {
encode zstd gzip
# API : même préfixe `/api` que le proxy Vite en développement, retiré
# avant de transmettre au backend (qui expose ses routes sous
# `/editions/...`, pas `/api`).
handle /api/* {
uri strip_prefix /api
reverse_proxy 127.0.0.1:8000
}
# Reste de l'application : le frontend statique (nginx).
handle {
reverse_proxy 127.0.0.1:8080
}
}
+105 -17
View File
@@ -1,24 +1,46 @@
# Déploiement
Le workflow [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml)
se déclenche sur **chaque push sur `main`** :
Le pipeline [`.gitlab-ci.yml`](../.gitlab-ci.yml) se déclenche sur **chaque
push** :
1. build de l'image `backend` ;
2. push sur `ghcr.io/<owner>/<repo>-backend` (tags `latest` et `sha-<commit>`) ;
1. tests (backend : `ruff` + `pytest` + `alembic check` ; frontend :
`typecheck` + `lint` + `build` + `smoke`) ;
2. sur `main` seulement : build des images `backend` et `frontend`, poussées
sur le registre de conteneurs du projet GitLab ;
3. copie de `deploy/docker-compose.prod.yml` sur le VPS par SSH ;
4. `docker compose -f docker-compose.prod.yml pull && up -d`.
4. `docker compose pull`, migration Alembic, `up -d`.
## Secrets GitHub Actions requis
Le VPS n'expose rien directement : un **Caddy déjà installé sur le
serveur** (service système, hors de ce dépôt) reçoit le trafic public sur
443 et reverse-proxy vers les deux conteneurs, qui n'écoutent que sur
`127.0.0.1`.
| Secret | Description |
|----------------|----------------------------------------------------------------|
## Étape 0 — pousser le dépôt sur GitLab
```bash
# Créer d'abord un projet vide sur GitLab (sans README ni .gitignore), puis :
git remote add origin git@gitlab.com:<namespace>/<projet>.git
git push -u origin main
```
## Variables CI/CD à définir (Paramètres → CI/CD → Variables)
| Variable | Description |
|-------------------|-------------------------------------------------------------------|
| `SSH_HOST` | Hôte du VPS |
| `SSH_USER` | Utilisateur SSH |
| `SSH_KEY` | Clé privée SSH (format OpenSSH) |
| `SSH_KEY` | Clé privée SSH (format OpenSSH) — **masquée**, sur `main` seulement|
| `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) |
| `GHCR_TOKEN` | PAT avec scope `read:packages`, pour que le VPS tire l'image |
| `VITE_EDITION_ID` | UUID de l'édition de production — **à ajouter après** l'étape 3 |
## Préparation du VPS (une fois)
`CI_REGISTRY`, `CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD` et
`CI_REGISTRY_IMAGE` sont fournies automatiquement par GitLab, rien à
configurer.
La clé publique correspondant à `SSH_KEY` doit être dans le
`~/.ssh/authorized_keys` de `SSH_USER` sur le VPS.
## Étape 1 — préparation du VPS (une fois)
```bash
mkdir -p /opt/benevoles
@@ -28,21 +50,87 @@ POSTGRES_USER=benevoles
POSTGRES_PASSWORD=<mot de passe fort>
POSTGRES_DB=benevoles
JWT_SECRET=<secret fort ; openssl rand -hex 32>
PUBLIC_BASE_URL=https://benevoles.mon-festival.fr
PUBLIC_BASE_URL=https://benevoles.mon-domaine.fr
# Emails (récapitulatifs de planning + liens de connexion). Laisser
# EMAIL_ENABLED=false tant que le SMTP n'est pas prêt : les emails sont alors
# seulement journalisés.
EMAIL_ENABLED=true
SMTP_HOST=smtp.mon-festival.fr
SMTP_HOST=smtp.mon-domaine.fr
SMTP_PORT=587
SMTP_USER=no-reply@mon-festival.fr
SMTP_USER=no-reply@mon-domaine.fr
SMTP_PASSWORD=<mot de passe SMTP>
SMTP_STARTTLS=true
SMTP_SSL=false
SMTP_FROM=Festival <no-reply@mon-festival.fr>
SMTP_FROM=Festival <no-reply@mon-domaine.fr>
EOF
```
Le workflow dépose `docker-compose.prod.yml` dans ce dossier et l'utilise tel
quel.
Le pipeline dépose `docker-compose.prod.yml` dans ce dossier et l'utilise tel
quel — il n'y a rien d'autre à y copier.
## Étape 2 — premier déploiement (backend + base seuls)
Le frontend a besoin de l'UUID d'une édition pour être construit (voir plus
bas) : le tout premier déploiement se fait donc en deux temps.
1. Sur GitLab, lancer le pipeline sur `main` normalement (Push → CI/CD →
Pipelines, ou un simple commit). Le job `build-frontend` échouera ou
produira une image sans édition tant que `VITE_EDITION_ID` n'existe pas :
c'est attendu, on l'ignore pour l'instant.
2. Une fois `build-backend` et `deploy` passés, `db` et `backend` tournent
sur le VPS.
## Étape 3 — créer l'édition de production
```bash
ssh SSH_USER@SSH_HOST
cd /opt/benevoles
docker compose -f docker-compose.prod.yml run --rm backend \
uv run python -m app.bootstrap \
--name "Festival 2026" --start 2026-07-01 --end 2026-07-04 \
--email coordination@mon-domaine.fr --password <mot-de-passe-solide>
```
La commande affiche l'UUID de l'édition créée. Récupérer aussi celui-ci à
tout moment avec :
```bash
docker compose -f docker-compose.prod.yml exec -T db \
psql -U benevoles -tAc "select id from edition order by created_at desc limit 1"
```
## Étape 4 — brancher le frontend sur cette édition
Dans GitLab, ajouter la variable CI/CD `VITE_EDITION_ID` avec cet UUID, puis
relancer le pipeline sur `main` (bouton *Retry* sur le dernier pipeline, ou un
nouveau commit). Cette fois `build-frontend` construit une image qui pointe
sur la bonne édition, et `deploy` démarre le service `frontend`.
`VITE_EDITION_ID` ne change plus ensuite : elle ne se touche à nouveau que
pour une nouvelle édition (festival de l'année suivante) qu'on voudrait
publier sur ce même domaine.
## Étape 5 — le vhost Caddy
Caddy tourne déjà sur ce serveur (service système) : ajouter le bloc
[`deploy/Caddyfile.benevoles`](./Caddyfile.benevoles) à son `Caddyfile` (ou au
fichier qu'il importe pour les sites), avec le vrai nom de domaine, puis :
```bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Le DNS du domaine doit déjà pointer (enregistrement `A`/`AAAA`) vers ce VPS —
Caddy obtient et renouvelle seul le certificat HTTPS (Let's Encrypt) au
premier accès.
Vérifier ensuite que `PUBLIC_BASE_URL` dans le `.env` du VPS (étape 1)
correspond bien à `https://<ce domaine>` — c'est cette valeur qui est utilisée
dans les liens des emails envoyés aux bénévoles.
## Déploiements suivants
Un simple push sur `main` suffit : tests → build → déploiement → migration →
redémarrage, sans aucune intervention sur le VPS ni sur Caddy.
+16 -3
View File
@@ -3,7 +3,12 @@ name: benevoles
# Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à
# côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD,
# POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL et les variables SMTP_* / EMAIL_ENABLED.
# BACKEND_IMAGE est exporté par le workflow de déploiement.
# BACKEND_IMAGE / FRONTEND_IMAGE sont exportées par le pipeline de déploiement
# (.gitlab-ci.yml).
#
# Les deux services ne publient que sur `127.0.0.1` : c'est le Caddy du VPS
# (hors de ce compose, voir deploy/README.md) qui reçoit le trafic public et
# reverse-proxy vers ces ports.
services:
db:
@@ -22,7 +27,7 @@ services:
retries: 10
backend:
image: ${BACKEND_IMAGE:-ghcr.io/OWNER/REPO-backend:latest}
image: ${BACKEND_IMAGE:-registry.gitlab.com/OWNER/REPO/backend:latest}
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
ENVIRONMENT: production
@@ -41,7 +46,15 @@ services:
db:
condition: service_healthy
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
restart: unless-stopped
frontend:
image: ${FRONTEND_IMAGE:-registry.gitlab.com/OWNER/REPO/frontend:latest}
depends_on:
- backend
ports:
- "127.0.0.1:8080:80"
restart: unless-stopped
volumes:
+28
View File
@@ -0,0 +1,28 @@
# Image de production : build Vite statique, servi par nginx.
#
# `VITE_API_MODE` et `VITE_EDITION_ID` sont des variables **de build** (Vite
# les fige dans le bundle, il n'y a rien à configurer à l'exécution) :
# l'édition ciblée doit donc déjà exister côté backend avant de construire
# cette image. Voir deploy/README.md pour la procédure de première mise en
# service.
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
ARG VITE_API_MODE=http
ARG VITE_EDITION_ID
ENV VITE_API_MODE=$VITE_API_MODE
ENV VITE_EDITION_ID=$VITE_EDITION_ID
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Fichiers hachés par Vite (contenu du nom = jamais modifiés une fois
# publiés) : cache long côté navigateur.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Application monopage (react-router-dom) : toute route inconnue du
# serveur retombe sur index.html, le routeur prend le relais côté client.
location / {
try_files $uri $uri/ /index.html;
}
}