Repository navigation
Expand file tree
/
Copy pathdocker-compose.staging.yml
More file actions
242 lines (235 loc) · 11.3 KB
/
Copy pathdocker-compose.staging.yml
File metadata and controls
242 lines (235 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# Piloti — stack STAGING.
#
# Stack Docker Compose parallèle et totalement isolée de la prod (voir D-017
# dans DECISIONS.md). Même hôte que la prod, mais aucun conteneur, réseau ou
# volume partagé.
#
# Architecture : Internet → Cloudflare Access (email du propriétaire) →
# cloudflared (tunnel STAGING, distinct de celui de prod)
# → Traefik (staging) → app Next.js (staging)
#
# ZERO port exposé sur la machine hôte, comme en prod. Tout trafic entrant
# transite par le tunnel Cloudflare staging.
#
# RGPD : cette base ne doit JAMAIS contenir de données réelles (mineurs,
# finances). Volume vierge à chaque déploiement, seedé par le service `seed`
# avec des données factices (`pnpm db:seed`). Voir docs/DEPLOYMENT.md.
#
# IMPORTANT — isolation vs la prod :
# - Lancer avec un nom de projet dédié pour ne partager AUCUNE ressource
# Docker avec `docker-compose.yml` (préfixe des conteneurs/réseaux/volumes) :
# COMPOSE_PROJECT_NAME=piloti-staging docker compose -f docker-compose.staging.yml up -d
# (ou export COMPOSE_PROJECT_NAME=piloti-staging dans le shell/CI avant l'appel).
# - Volumes nommés explicitement `piloti-staging-*` (indépendants du nom de
# projet, donc pas d'ambiguïté possible même si la variable ci-dessus est
# oubliée).
#
# Démarrage (après les étapes manuelles décrites dans docs/DEPLOYMENT.md) :
# cp .env.example .env.staging && $EDITOR .env.staging
# COMPOSE_PROJECT_NAME=piloti-staging docker compose -f docker-compose.staging.yml --env-file .env.staging build
# COMPOSE_PROJECT_NAME=piloti-staging docker compose -f docker-compose.staging.yml --env-file .env.staging up -d
#
# Enchaînement au démarrage : migrate → seed → app.
# 1. `migrate` : `prisma migrate deploy` sur le volume staging (vierge ou déjà seedé).
# 2. `seed` : one-shot, attend la fin de `migrate`, lance `pnpm db:seed`
# (données factices uniquement — jamais un export de prod).
# DESTRUCTIF : le script vide la base avant de la recréer
# (pas de doublon au redéploiement, mais rien n'est conservé).
# Il refuse de tourner sans SEED_CONFIRM=1 (cf. prisma/seed.ts).
# 3. `app` : démarre seulement une fois `seed` terminé avec succès.
#
# Logs : docker compose -f docker-compose.staging.yml logs -f app
# Nettoyage complet (⚠ détruit les données factices staging, jamais la prod) :
# docker compose -f docker-compose.staging.yml down -v
services:
# ---------------------------------------------------------------------------
# Migrations Prisma — one-shot, exit 0, sur le volume staging exclusivement.
# ---------------------------------------------------------------------------
migrate:
build:
context: .
target: migrate
args:
NODE_IMAGE_TAG: ${NODE_IMAGE_TAG:?NODE_IMAGE_TAG manquant (voir .env.example)}
# Tag DÉDIÉ au staging : ne jamais réutiliser `piloti:migrate` (prod), sinon
# un `build` staging écrase l'image de la prod.
image: piloti-staging:migrate
environment:
DATABASE_URL: file:/data/piloti.db
volumes:
- piloti-staging-data:/data
networks:
- internal
restart: "no"
# ---------------------------------------------------------------------------
# Seed — one-shot, données factices uniquement (RGPD : jamais de données
# réelles en staging). Utilise le stage `dev` (et non `migrate`) : `pnpm
# db:seed` exécute `tsx prisma/seed.ts`, qui importe `src/lib/auth` — donc il
# faut le code source complet (absent de l'image `migrate`, minimale). Comme
# `src/lib/auth` plante à l'import sans BETTER_AUTH_SECRET (fail-fast), le seed
# a besoin du même secret que l'app.
# ---------------------------------------------------------------------------
seed:
build:
context: .
target: dev
args:
NODE_IMAGE_TAG: ${NODE_IMAGE_TAG:?NODE_IMAGE_TAG manquant (voir .env.example)}
image: piloti-staging:seed
environment:
DATABASE_URL: file:/data/piloti.db
BETTER_AUTH_SECRET: ${STAGING_BETTER_AUTH_SECRET:?STAGING_BETTER_AUTH_SECRET manquant}
# `prisma/seed.ts` EFFACE la base visée et refuse de tourner sans cette
# confirmation dès que la cible n'est pas la base de dev locale — prod et
# staging partageant le même DATABASE_URL, c'est le seul garde-fou possible.
SEED_CONFIRM: "1"
# Mot de passe des comptes factices. Jamais dans le code (l'ancien était
# committé sur un dépôt public, donc fuité définitivement).
SEED_PASSWORD: ${STAGING_SEED_PASSWORD:?STAGING_SEED_PASSWORD manquant}
volumes:
- piloti-staging-data:/data
networks:
- internal
depends_on:
migrate:
condition: service_completed_successfully
command: ["sh", "-c", "pnpm db:seed"]
restart: "no"
# ---------------------------------------------------------------------------
# App Next.js. Pas de `ports:` → uniquement accessible via Traefik staging.
# ---------------------------------------------------------------------------
app:
build:
context: .
target: runner
args:
NODE_IMAGE_TAG: ${NODE_IMAGE_TAG:?NODE_IMAGE_TAG manquant (voir .env.example)}
# Tag DÉDIÉ au staging : jamais `piloti:latest` (prod).
image: piloti-staging:latest
environment:
# Vraie image déployée (identique à la prod), mais secret et URL propres
# au staging — jamais partagés avec la prod.
NODE_ENV: production
DATABASE_URL: file:/data/piloti.db
BETTER_AUTH_SECRET: ${STAGING_BETTER_AUTH_SECRET:?STAGING_BETTER_AUTH_SECRET manquant}
BETTER_AUTH_URL: ${STAGING_BETTER_AUTH_URL:?STAGING_BETTER_AUTH_URL manquant}
# Emails (Resend) + notifications push (VAPID) — optionnels : vides →
# canal désactivé. Ne jamais pointer vers les identifiants de prod pour
# éviter d'envoyer de vrais emails/push depuis un environnement de test.
RESEND_API_KEY: ${STAGING_RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${STAGING_RESEND_FROM_EMAIL:-}
VAPID_PUBLIC_KEY: ${STAGING_VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${STAGING_VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${STAGING_VAPID_SUBJECT:-}
# #163 — durée de conservation du journal d'audit, en années. Vide = 10.
AUDIT_RETENTION_YEARS: ${STAGING_AUDIT_RETENTION_YEARS:-}
# LEGAL-02 — identité légale. Contrairement à la prod, non obligatoire :
# le staging n'est pas un site publié au sens de la LCEN et ne doit pas
# afficher l'identité réelle du groupe. Valeurs de test par défaut, pour
# que les pages légales restent vérifiables sans rien configurer.
ORG_NAME: ${STAGING_ORG_NAME:-Éditeur de test (staging)}
ORG_GROUP: ${STAGING_ORG_GROUP:-Groupe de test (staging)}
ORG_ADDRESS: ${STAGING_ORG_ADDRESS:-1 rue de la Recette, 00000 Staging}
ORG_EMAIL: ${STAGING_ORG_EMAIL:-contact@example.invalid}
ORG_PRIVACY_EMAIL: ${STAGING_ORG_PRIVACY_EMAIL:-rgpd@example.invalid}
ORG_PUBLICATION_DIRECTOR: ${STAGING_ORG_PUBLICATION_DIRECTOR:-Responsable de test}
ORG_HOSTING_PROVIDER: ${STAGING_ORG_HOSTING_PROVIDER:-Environnement de test, non publié}
volumes:
- piloti-staging-data:/data
- piloti-staging-uploads:/app/var/uploads
depends_on:
seed:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 3s
start_period: 15s
retries: 3
networks:
# `internal` : chemin app↔traefik (isolé d'Internet), propre au staging.
- internal
# `tunnel` (non-internal) : SORTIE Internet nécessaire aux notifications
# push et aux emails. Aucun port publié → l'ENTRÉE reste uniquement via
# Traefik→cloudflared staging.
- tunnel
restart: unless-stopped
labels:
- "traefik.enable=true"
# Réseau du *projet staging* (COMPOSE_PROJECT_NAME=piloti-staging),
# jamais `piloti_internal` (prod) — sinon Traefik chercherait l'app dans
# le mauvais réseau Docker.
- "traefik.docker.network=piloti-staging_internal"
- "traefik.http.routers.piloti-staging.rule=Host(`${STAGING_DOMAIN:?STAGING_DOMAIN manquant}`)"
- "traefik.http.routers.piloti-staging.entrypoints=web"
- "traefik.http.routers.piloti-staging.middlewares=security-headers@file,rate-limit@file"
- "traefik.http.services.piloti-staging.loadbalancer.server.port=3000"
# ---------------------------------------------------------------------------
# Traefik — reverse proxy interne, instance dédiée au staging. Pas de
# dashboard exposé. Réutilise les mêmes middlewares statiques que la prod
# (headers de sécurité, CSP) : aucune raison d'en avoir une version dégradée
# sur un environnement qui reste accessible depuis Internet (via Access).
# ---------------------------------------------------------------------------
traefik:
image: traefik:${TRAEFIK_IMAGE_TAG:?TRAEFIK_IMAGE_TAG manquant (voir .env.example)}
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=piloti-staging_internal"
- "--providers.file.directory=/etc/traefik/config"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--ping=true"
# Pas de --api.dashboard=true → pas d'exposition admin
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/config:/etc/traefik/config:ro
# SELinux (Fedora/RHEL) bloque l'accès au docker.sock depuis le conteneur —
# même contrainte qu'en prod (cf. docker-compose.yml).
security_opt:
- label=disable
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 30s
timeout: 3s
retries: 3
networks:
- internal
- tunnel
restart: unless-stopped
# ---------------------------------------------------------------------------
# Cloudflared — tunnel sortant STAGING, distinct de celui de prod (token
# différent, cf. docs/DEPLOYMENT.md). Token-based.
# ---------------------------------------------------------------------------
cloudflared:
image: cloudflare/cloudflared:${CLOUDFLARED_IMAGE_TAG:?CLOUDFLARED_IMAGE_TAG manquant (voir .env.example)}
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${STAGING_CLOUDFLARE_TUNNEL_TOKEN:?STAGING_CLOUDFLARE_TUNNEL_TOKEN manquant}
healthcheck:
test: ["CMD", "cloudflared", "--version"]
interval: 60s
timeout: 5s
retries: 3
networks:
- tunnel
restart: unless-stopped
depends_on:
traefik:
condition: service_healthy
networks:
# Réseau "internal" — TOTALEMENT isolé d'Internet, propre au projet staging
# (`internal: true` empêche l'app de faire des appels sortants intempestifs).
internal:
internal: true
# Réseau "tunnel" — non-internal car cloudflared a besoin d'Internet pour
# joindre l'edge Cloudflare. traefik est dual-homed sur les 2 réseaux.
tunnel:
volumes:
# Noms explicites piloti-staging-* : ne collisionnent jamais avec les
# volumes `piloti-data` / `piloti-uploads` de la prod, quel que soit
# COMPOSE_PROJECT_NAME.
piloti-staging-data:
name: piloti-staging-data
piloti-staging-uploads:
name: piloti-staging-uploads