Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
167 lines (161 loc) · 7.01 KB
/
Copy pathdocker-compose.yml
File metadata and controls
167 lines (161 loc) · 7.01 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
# Piloti — stack prod.
#
# Architecture : Internet → Cloudflare CDN/WAF → cloudflared tunnel
# → Traefik → app Next.js
#
# ZERO port exposé sur la machine hôte. Tout trafic entrant transite par
# le tunnel Cloudflare (chiffré, NAT-traversal, sans IP publique).
#
# Démarrage :
# cp .env.example .env && $EDITOR .env
# docker compose build
# docker compose up -d
#
# Logs : docker compose logs -f app
# Migration : appliquée automatiquement par le service `migrate` au boot.
services:
# ---------------------------------------------------------------------------
# Migrations Prisma — one-shot, exit 0, l'app ne démarre qu'après.
# ---------------------------------------------------------------------------
migrate:
build:
context: .
target: migrate
args:
NODE_IMAGE_TAG: ${NODE_IMAGE_TAG:?NODE_IMAGE_TAG manquant (voir .env.example)}
image: piloti:migrate
environment:
DATABASE_URL: file:/data/piloti.db
volumes:
- piloti-data:/data
networks:
- internal
restart: "no"
# ---------------------------------------------------------------------------
# App Next.js. Pas de `ports:` → uniquement accessible via Traefik.
# ---------------------------------------------------------------------------
app:
build:
context: .
target: runner
args:
NODE_IMAGE_TAG: ${NODE_IMAGE_TAG:?NODE_IMAGE_TAG manquant (voir .env.example)}
image: piloti:latest
environment:
NODE_ENV: production
DATABASE_URL: file:/data/piloti.db
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET manquant}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:?BETTER_AUTH_URL manquant}
# Emails (Resend) + notifications push (VAPID) — optionnels : vides → canal désactivé.
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
# #163 — durée de conservation du journal d'audit, en années. Vide = 10.
# Une valeur invalide empêche l'app de démarrer (src/lib/audit-retention.ts).
AUDIT_RETENTION_YEARS: ${AUDIT_RETENTION_YEARS:-}
# LEGAL-02 / CONF-01 — identité légale publiée dans /mentions-legales,
# /cgu et /confidentialite. OBLIGATOIRES : les mentions légales sont une
# obligation (LCEN art. 6-III) et une instance qui les publierait vides
# serait en infraction — mieux vaut un déploiement qui refuse de démarrer
# qu'une page légale incomplète en ligne. Même garde-fou que
# BETTER_AUTH_SECRET ci-dessus.
ORG_NAME: ${ORG_NAME:?ORG_NAME manquant (éditeur du site au sens LCEN)}
ORG_GROUP: ${ORG_GROUP:?ORG_GROUP manquant (groupe SGDF desservi par l'instance)}
ORG_ADDRESS: ${ORG_ADDRESS:?ORG_ADDRESS manquant (adresse postale du siège)}
ORG_EMAIL: ${ORG_EMAIL:?ORG_EMAIL manquant (contact général du groupe)}
ORG_PRIVACY_EMAIL: ${ORG_PRIVACY_EMAIL:?ORG_PRIVACY_EMAIL manquant (référent RGPD)}
ORG_PUBLICATION_DIRECTOR: ${ORG_PUBLICATION_DIRECTOR:?ORG_PUBLICATION_DIRECTOR manquant (directeur de la publication)}
ORG_HOSTING_PROVIDER: ${ORG_HOSTING_PROVIDER:?ORG_HOSTING_PROVIDER manquant (nom et localisation de l'hébergeur)}
volumes:
- piloti-data:/data
- piloti-uploads:/app/var/uploads
depends_on:
migrate:
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). Traefik route via ce
# réseau (cf. label traefik.docker.network ci-dessous).
- internal
# `tunnel` (non-internal) : SORTIE Internet nécessaire aux notifications
# push (passerelles Apple/Google/Mozilla) et aux emails (Resend). Aucun
# port n'est publié → l'ENTRÉE reste uniquement via Traefik→cloudflared.
- tunnel
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.docker.network=piloti_internal"
- "traefik.http.routers.piloti.rule=Host(`${TRAEFIK_DOMAIN:?TRAEFIK_DOMAIN manquant}`)"
- "traefik.http.routers.piloti.entrypoints=web"
- "traefik.http.routers.piloti.middlewares=security-headers@file,rate-limit@file"
- "traefik.http.services.piloti.loadbalancer.server.port=3000"
# ---------------------------------------------------------------------------
# Traefik — reverse proxy interne. Pas de dashboard exposé en prod.
# ---------------------------------------------------------------------------
traefik:
# v3.6 minimum pour parler à un daemon Docker Engine 29+ (API >= 1.45).
# v3.3 négocie API 1.24 en dur et plante en startup avec Docker 29.
image: traefik:${TRAEFIK_IMAGE_TAG:?TRAEFIK_IMAGE_TAG manquant (voir .env.example)}
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=piloti_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.
# On désactive le label SELinux uniquement pour ce service — Traefik est
# déjà confiné par son réseau (pas de port exposé) + un sock en read-only.
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 vers Cloudflare. Token-based.
# ---------------------------------------------------------------------------
cloudflared:
image: cloudflare/cloudflared:${CLOUDFLARED_IMAGE_TAG:?CLOUDFLARED_IMAGE_TAG manquant (voir .env.example)}
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:?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. app↔traefik uniquement.
# `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:
piloti-data:
piloti-uploads: