Déployer OpsFlux sur un VPS — guide complet#
Guide pas-à-pas pour mettre OpsFlux en production sur un VPS générique (Hostinger, OVH, Hetzner, DigitalOcean, AWS Lightsail, Scaleway, …) avec Docker Compose + Traefik.
Trois chemins selon ton préféré :
- Vanilla Docker Compose (le plus de contrôle, le plus simple à comprendre) — c'est le chemin principal de ce guide, §3 à §13.
- Avec un control plane (Dokploy, Coolify, EasyPanel, Caprover, Portainer, …) — §14 montre comment mapper le compose sur chaque plateforme. Le compose lui-même est identique.
- PaaS géré (Render, Railway, Fly.io, …) — pas couvert ici car chacun a ses spécificités ; suivre leur doc native pour ingérer un compose.
Pré-requis lecteur : familiarité Linux, DNS, Docker, Let's Encrypt.
Pour une vue d'ensemble de l'architecture, voir STACK.md.
Table des matières#
- Pré-requis VPS
- DNS — enregistrements à créer
- Préparer le serveur
- Installer Docker + Compose
- Cloner le repo
- Configurer
.env - Mettre en place Traefik
- Premier boot — build + migrations + seed
- 8.1 — Que se passe-t-il pendant ce premier boot ?
- 8.2 — Adapter le seed à votre contexte
- 8.3 — Re-seeder une étape spécifique
- Vérification post-déploiement
- Connexion initiale + sécurisation
- Sauvegardes
- Mises à jour
- Recovery — situations classiques
- Déployer avec un control plane (Dokploy / Coolify / EasyPanel / Caprover / Portainer / …)
- Annexes
1. Pré-requis VPS#
Dimensionnement minimal#
| Profil | RAM | vCPU | Disque | Notes |
|---|---|---|---|---|
| Demo / staging | 4 Go | 2 | 40 Go | DB + 1 worker uvicorn |
| Prod < 50 users | 8 Go | 4 | 80 Go | 4 workers, sauvegardes locales |
| Prod 50-500 users | 16 Go | 6 | 160 Go | + agent-worker, S3 storage externe recommandé |
OS#
- Ubuntu 24.04 LTS ou Debian 12 — ce guide utilise Ubuntu.
- Architecture
x86_64(les images Dockerpgvector/pgvector:pg16etjgraph/drawio:29.6.7ne sont pas testées sur ARM64).
Comptes & accès#
- Accès root SSH (ou utilisateur
sudo) - Un nom de domaine que vous contrôlez (les enregistrements DNS sont pointés sur l'IPv4 du VPS)
- Ports
80et443ouverts sur le firewall (ufw allow 80,443/tcp) - Un compte SMTP transactionnel (Mailu auto-hébergé, SendGrid, OVH, Postmark, …) pour l'envoi des notifications
2. DNS — enregistrements à créer#
Remplacez opsflux.io par votre domaine. Tous ces enregistrements
sont nécessaires pour que les certificats Let's Encrypt s'émettent.
| Type | Hôte | Valeur | Service couvert |
|---|---|---|---|
| A | app.opsflux.io |
<IP du VPS> |
Frontend SPA |
| A | api.opsflux.io |
<IP du VPS> |
Backend FastAPI |
| A | mcp.opsflux.io |
<IP du VPS> |
MCP Gateway (alias backend) |
| A | ext.opsflux.io |
<IP du VPS> |
Portail externe paxlog |
| A | drawio.opsflux.io |
<IP du VPS> |
Éditeur Draw.io |
| A | db.opsflux.io |
<IP du VPS> |
pgAdmin (réservé superadmin) |
| A | docs.opsflux.io |
<IP du VPS> |
Documentation (MkDocs Material) |
| A | www.opsflux.io |
<IP du VPS> |
Site marketing (vitrine) |
| A | opsflux.io (apex) |
<IP du VPS> |
Redirige → www. |
TTL conseillé : 300 (5 min) pendant la mise en place, puis 3600.
CAA recommandé (optionnel, durcissement) :
0 issue "letsencrypt.org"— empêche n'importe quelle autre CA d'émettre un certificat pour votre domaine.
Vérifier que tout est propagé avant de continuer :
for sub in app api mcp ext drawio db docs www; do
echo -n "$sub.opsflux.io → "
dig +short A "$sub.opsflux.io" | head -1
done
dig +short A opsflux.io
Toutes les lignes doivent retourner l'IP du VPS.
2.1 — Tester sans acheter de domaine (sslip.io / nip.io)#
Pour une instance de test ou démo, vous pouvez sauter l'étape DNS en utilisant un service "magic DNS" qui résout n'importe quel sous-domaine vers une IP encodée dans le nom :
Toutes les routes Traefik deviennent automatiquement résolvables :
app.72-60-188-156.sslip.io, api.72-60-188-156.sslip.io, etc.
Limites : - Certains FAI résidentiels ou pare-feu d'entreprise bloquent sslip.io / nip.io (réponse
403 Web Filter Violation). Tester d'abord depuis le VPS lui-même :curl -ks https://app.<DOMAIN>doit retourner du HTML. - Let's Encrypt accepte sslip.io mais l'usage est partagé par beaucoup de monde — risque de hit le rate-limit "5 certs/semaine par domaine de second niveau". Préférer un vrai domaine pour la prod. - Pas adapté à un usage end-user — utilisateurs externes peuvent être bloqués par leur DNS/proxy.
3. Préparer le serveur#
# Connexion
ssh root@<ip-vps>
# Mise à jour
apt update && apt upgrade -y
# Outils de base
apt install -y curl git ca-certificates ufw fail2ban htop
# Hostname (optionnel mais propre dans les logs)
hostnamectl set-hostname opsflux-prod
# Fuseau horaire (cohérence avec les timestamps DB)
timedatectl set-timezone Europe/Paris # ou Africa/Douala, etc.
# Firewall
ufw allow OpenSSH
ufw allow 80,443/tcp
ufw --force enable
# fail2ban (optionnel mais recommandé)
systemctl enable --now fail2ban
Créer un utilisateur non-root pour Docker (recommandé)#
adduser opsflux
usermod -aG sudo opsflux
mkdir -p /home/opsflux/.ssh
cp ~/.ssh/authorized_keys /home/opsflux/.ssh/
chown -R opsflux:opsflux /home/opsflux/.ssh
chmod 700 /home/opsflux/.ssh
chmod 600 /home/opsflux/.ssh/authorized_keys
Désactiver le SSH root après vérification que ssh opsflux@<ip> marche :
4. Installer Docker + Compose#
Docker Engine (version officielle, pas snap)#
# Désinstaller toute ancienne version
apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null
# Repo officiel Docker
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Permettre à `opsflux` d'utiliser docker sans sudo
usermod -aG docker opsflux
# Vérification
docker --version
docker compose version
Note : sur Ubuntu 24.04 le plugin
docker compose(V2) remplacedocker-compose(V1, déprécié). On utilisedocker compose <cmd>, pasdocker-compose <cmd>.
5. Cloner le repo#
su - opsflux # ou se déconnecter/reconnecter en opsflux
cd /opt
sudo mkdir opsflux && sudo chown opsflux:opsflux opsflux
cd opsflux
git clone https://github.com/hmunyeku/OPSFLUX.git .
git checkout main
Repo privé ? Utiliser un deploy key GitHub :
ssh-keygen -t ed25519 -f ~/.ssh/opsflux_deploy -N ""puis ajouter la clé publique dans GitHub → Settings → Deploy Keys.
6. Configurer .env#
Éditer .env (cf. ../.env.example pour la
référence canonique de chaque variable). Variables critiques à
modifier avant tout docker compose up :
6.1 — Secrets aléatoires (obligatoires)#
# Générer en une commande, copier-coller dans .env
echo "SECRET_KEY=$(openssl rand -hex 32)"
echo "JWT_SECRET_KEY=$(openssl rand -hex 32)"
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
echo "ENCRYPTION_KEY=$(python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
echo "OPSFLUX_INTERNAL_TOKEN=$(openssl rand -hex 32)"
Si Python n'est pas installé hors Docker :
docker run --rm python:3.12-slim sh -c "pip install -q cryptography && python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
6.2 — Domaines#
Toutes les Traefik labels du docker-compose.yml utilisent la variable
DOMAIN. Elle templater toutes les URLs app.<DOMAIN>, api.<DOMAIN>,
etc.
DOMAIN=opsflux.io # remplace par TON domaine
APP_URL=https://app.opsflux.io
API_URL=https://api.opsflux.io
WEB_URL=https://www.opsflux.io
API_BASE_URL=https://api.opsflux.io
FRONTEND_URL=https://app.opsflux.io
ALLOWED_HOSTS=api.opsflux.io,app.opsflux.io,db.opsflux.io,mcp.opsflux.io,ext.opsflux.io,docs.opsflux.io
ALLOWED_ORIGINS=https://app.opsflux.io,https://ext.opsflux.io,https://api.opsflux.io
ENVIRONMENT=production
6.3 — Base de données#
DATABASE_URL doit pointer vers le service db du compose et utiliser
le même mot de passe que POSTGRES_PASSWORD :
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<le hex généré au 6.1>
POSTGRES_DB=opsflux
DATABASE_URL=postgresql+asyncpg://postgres:<le-meme-hex>@db:5432/opsflux
6.4 — SMTP (obligatoire pour invitations / reset mdp)#
Exemple SendGrid :
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USERNAME=apikey
SMTP_PASSWORD=<la clé API SendGrid>
SMTP_FROM_ADDRESS=noreply@opsflux.io
SMTP_FROM_NAME=OpsFlux
SMTP_USE_TLS=true
Exemple Mailu auto-hébergé :
SMTP_HOST=mail.opsflux.io
SMTP_PORT=465
SMTP_USERNAME=admin@opsflux.io
SMTP_PASSWORD=<password de la mailbox>
SMTP_USE_TLS=true
6.5 — Premier admin#
FIRST_SUPERUSER=admin@opsflux.io
FIRST_SUPERUSER_PASSWORD=<un mot de passe robuste — sera à changer au 1er login>
FIRST_ENTITY_CODE=ACME
FIRST_ENTITY_NAME=ACME Corporation
FIRST_ENTITY_COUNTRY=FR
FIRST_ENTITY_TIMEZONE=Europe/Paris
FIRST_ENTITY_CURRENCY=EUR
6.6 — Optionnels#
SENTRY_DSN: créer un projet sur sentry.io → coller le DSN.ANTHROPIC_API_KEY: nécessaire pour l'agent IA support / TravelWiz NLP. Sans, ces features sont désactivées (pas d'erreur).STORAGE_BACKEND=s3+ clés S3 : si vous voulez décharger les uploads vers un bucket externe (recommandé en prod).
7. Mettre en place Traefik#
Le docker-compose.yml n'embarque pas Traefik — il l'attend sur
un réseau Docker externe partagé. Le nom de ce réseau est paramétrable
via TRAEFIK_NETWORK dans .env ; le défaut dokploy-network
correspond à ce qu'install Dokploy. Pour les autres plateformes, voir
le tableau §14.
Si tu n'utilises pas de control plane qui fournit Traefik (Dokploy / Coolify / EasyPanel / Caprover / …), il faut le monter à la main : deux options ci-dessous.
7.1 — Option A : Traefik standalone (recommandé hors control plane)#
Créer un compose Traefik dédié. On nomme le réseau partagé proxy
ici ; tu peux choisir n'importe quel nom — il faudra juste le mettre
dans le .env OpsFlux via TRAEFIK_NETWORK=<ce-nom>.
mkdir -p /opt/traefik && cd /opt/traefik
mkdir letsencrypt dynamic
touch letsencrypt/acme.json && chmod 600 letsencrypt/acme.json
/opt/traefik/docker-compose.yml :
services:
traefik:
image: traefik:v3.1
container_name: traefik
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=proxy"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.email=admin@opsflux.io"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
- ./dynamic:/etc/traefik/dynamic:ro
networks:
- proxy
networks:
proxy:
external: true
/opt/traefik/dynamic/redirect.yml (le docker-compose.yml d'OpsFlux
utilise redirect-to-https@file) :
Créer le réseau partagé puis démarrer Traefik :
docker network create proxy
cd /opt/traefik && docker compose up -d
docker logs -f traefik # vérifier "Configuration loaded"
Et dans le .env OpsFlux :
7.2 — Option B : nginx-proxy + acme-companion#
Si vous préférez nginx, vous devrez réécrire les labels Docker du
docker-compose.yml en utilisant les variables VIRTUAL_HOST /
LETSENCRYPT_HOST. Non couvert ici — Traefik est le chemin le mieux
testé.
7.3 — Sécuriser pgAdmin (recommandé)#
Le sous-domaine db.<DOMAIN> expose pgAdmin avec authentification
basique. À restreindre par IP en prod. Ajouter dans
/opt/traefik/dynamic/redirect.yml :
http:
middlewares:
redirect-to-https:
redirectScheme:
scheme: https
permanent: true
pgadmin-allowlist:
ipAllowList:
sourceRange:
- "<votre-ip-bureau>/32"
- "<vpn>/24"
Puis dans docker-compose.yml (service pgadmin), ajouter :
- "traefik.http.routers.${STACK_NAME:-opsflux}-pgadmin-websecure.middlewares=pgadmin-allowlist@file"
7.4 — Certificat SSL wildcard#
Par défaut le docker-compose.yml demande un cert par sous-domaine
via le resolver ACME letsencrypt (HTTP-01). Concrètement = 7
challenges LE successifs au premier deploy ; si l'un foire, rate-limit
garanti. Avec un wildcard *.<DOMAIN>, on tombe à un seul cert pour
tout.
Décision en 30 secondes :
| Ta situation | Le bon scénario |
|---|---|
| Tu n'as rien et tu ne veux pas toucher au DNS | Laisse les défauts (HTTP-01 par sous-domaine, mode actuel de la prod). |
| Tu as un compte Cloudflare / OVH / Route53 / DigitalOcean / etc. | Scénario A : Let's Encrypt DNS-01. Gratuit. Cert renouvelé tout seul. |
Tu as déjà un .crt + .key wildcard (Sectigo, RapidSSL, etc.) |
Scénario B : upload sur le serveur Traefik. |
| Tu déploies en intranet/LAN, pas d'Internet sortant | Scénario C : skip TLS (cert auto-signé Traefik). |
Pré-requis commun aux scénarios A et B : tu dois pouvoir éditer la config Traefik elle-même — pas seulement le compose OpsFlux. Selon ton hébergement :
- Traefik standalone (§7.1) → tu édites
/opt/traefik/docker-compose.yml+/opt/traefik/dynamic/*.yml. Plein contrôle. - Dokploy → UI →
Settings→Server→Traefik. Permet d'ajouter des--certificatesresolvers.*au command et de monter des fichiers dans/etc/dokploy/traefik/dynamic/.docker restart traefikaprès modif. - Coolify → UI →
Server→Proxy→ ongletTraefik(ouDynamic Configurations). Coolify expose la config statique +/data/coolify/proxy/dynamic/. - EasyPanel → UI →
Settings→Traefik. Volumes Traefik dans/etc/easypanel/traefik/. - Caprover → fichier
/captain/data/config-override/côté serveur, ou via la CLI Caprover. Caprover repackage Traefik dans une stack Swarm — moins flexible, parfois plus simple de coller un Traefik à côté en standalone (§7.1) et de le brancher dans le réseau Caprover. - Portainer + Traefik → édite directement le compose Traefik que tu as déployé toi-même. Comme §7.1.
- PaaS géré (Render, Railway, Fly.io, …) → ils gèrent leur propre TLS de bout en bout, les scénarios ci-dessous ne s'appliquent pas. Suivre leur doc native ; ils servent souvent un wildcard sur leur propre domaine, et un cert dédié si tu rattaches un domaine custom.
Le docker-compose.yml OpsFlux expose une variable CERT_RESOLVER
(défaut : letsencrypt) qui suffit à basculer entre les modes.
Scénario A — Wildcard Let's Encrypt DNS-01 (gratuit)#
Supporté tant que ton DNS provider est dans la liste lego (Cloudflare, OVH, Route53, DigitalOcean, Gandi, Hetzner, …). Pour Hostinger (non supporté nativement par lego), déléguer la zone à Cloudflare gratuit.
Côté Traefik (une seule fois) — ajouter au command: du conteneur
Traefik :
- "--certificatesresolvers.letsencrypt-dns.acme.email=admin@opsflux.io"
- "--certificatesresolvers.letsencrypt-dns.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt-dns.acme.dnschallenge=true"
- "--certificatesresolvers.letsencrypt-dns.acme.dnschallenge.provider=cloudflare"
Puis injecter les creds DNS provider au runtime :
environment:
CF_API_EMAIL: admin@opsflux.io
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN} # token Cloudflare scope Zone.Read+Edit sur opsflux.io
Variantes par provider : - OVH :
OVH_ENDPOINT,OVH_APPLICATION_KEY,OVH_APPLICATION_SECRET,OVH_CONSUMER_KEY- AWS Route53 :AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION- DigitalOcean :DO_AUTH_TOKEN- Gandi :GANDI_API_KEY
Restart Traefik :
Côté OpsFlux — une seule ligne dans .env :
Redeploy le compose OpsFlux. Au premier coup, Traefik fait un seul
ordre ACME DNS-01 vers LE → reçoit *.opsflux.io → le sert pour
tous les sous-domaines. Renouvellement auto avant expiration, sans
intervention.
Scénario B — Wildcard commercial (CA externe)#
Tu as déjà acheté wildcard.opsflux.io.crt + sa .key.
Côté Traefik (une seule fois) :
- Déposer les fichiers :
mkdir -p /opt/traefik/certs
cp wildcard.opsflux.io.crt /opt/traefik/certs/
cp wildcard.opsflux.io.key /opt/traefik/certs/
chmod 600 /opt/traefik/certs/*.key
- Monter
certs/dans le compose Traefik (ajouter auvolumes:du conteneur Traefik) :
- Déclarer le cert dans la config dynamique —
/opt/traefik/dynamic/wildcard.yml:
tls:
# Cert listé → Traefik l'associe à tous ses SAN (ici *.opsflux.io).
# Tout router servant un Host matching récupère ce cert via SNI
# sans qu'on touche aux labels OpsFlux.
certificates:
- certFile: /certs/wildcard.opsflux.io.crt
keyFile: /certs/wildcard.opsflux.io.key
# En plus, on en fait le cert par défaut → pour tout SNI imprévu
# (un sous-domaine non listé dans Traefik), Traefik renvoie le
# wildcard plutôt que son cert auto-signé.
stores:
default:
defaultCertificate:
certFile: /certs/wildcard.opsflux.io.crt
keyFile: /certs/wildcard.opsflux.io.key
- Restart Traefik et vérifier dans les logs que la config est chargée :
Côté OpsFlux — rien à changer. C'est une subtilité importante de
Traefik : quand il résout le cert pour un Host donné, il regarde
d'abord le file provider. Si un cert matche déjà le SNI, il
l'utilise et skip silencieusement le challenge ACME demandé par le
label certresolver=letsencrypt. Donc le default CERT_RESOLVER=letsencrypt
reste OK : Traefik servira ton wildcard, et les labels certresolver
deviennent un no-op silencieux.
Si tu veux explicitement désactiver tout l'ACME (par sécurité, pour être sûr qu'aucune requête ne parte vers Let's Encrypt), mets
CERT_RESOLVER=nonedans.env. Traefik logue un warning au boot (certificates resolver "none" not found) puis sert le wildcard du file provider exactement pareil.
Scénario C — Pas de TLS auto (intranet, dev LAN)#
→ Traefik ne contacte jamais Let's Encrypt et sert son cert auto-signé sur tous les Hosts. Warning navigateur (cert non reconnu) mais HTTPS fonctionnel pour un usage VPN/intranet ou dev en LAN.
Vérifier que le wildcard est bien servi#
# Affiche le cert servi pour app.opsflux.io
openssl s_client -connect app.opsflux.io:443 -servername app.opsflux.io \
</dev/null 2>/dev/null \
| openssl x509 -noout -text \
| grep -E "(Issuer|DNS:)"
Sortie attendue (scénarios A et B) :
Si tu vois DNS:app.opsflux.io au lieu de DNS:*.opsflux.io, c'est
que Traefik continue à tirer un cert HTTP-01 par sous-domaine. Causes
fréquentes :
- Config dynamique Traefik pas chargée — vérifier que le file provider pointe vers le bon dossier :
- Le
.crtest introuvable dans le conteneur Traefik : - Cert et clé ne matchent pas (mauvaise paire) :
- Pour le scénario A : challenge DNS-01 a échoué — chercher
acme: errordansdocker logs traefik. Souvent un token DNS qui n'a pas les permissions write sur la zone.
7.5 — Documentation : protéger /developer/* sur docs.<DOMAIN>#
Le portail documentation docs.<DOMAIN> sert deux espaces :
/enduser/*— public, pas d'auth (cette doc)/developer/*— gated, basicAuth Traefik (V1) → OpsFlux SSO (V2)
V1 : basicAuth (en place)#
Le docker-compose.yml déclare deux routeurs Traefik sur le même Host :
- Router public — rule
Host(docs.) && !PathPrefix(/developer) - Router gated — rule
Host(docs.) && PathPrefix(/developer), middlewarebasicauthavec credentials depuisDEV_DOCS_AUTH
Configurer les credentials :
# Générer un hash bcrypt pour user "alice" / password "Pa$$w0rd"
htpasswd -nbB alice 'Pa$$w0rd'
# → alice:$2y$05$xkXjVm8vKWgWv0vWyKK0Qe...
# Copier dans .env (DOUBLER les $ pour échapper l'interpolation Compose)
DEV_DOCS_AUTH=alice:$$2y$$05$$xkXjVm8vKWgWv0vWyKK0Qe.5C5z7l8mF7m8l5lEeZGGQwBhVXSHtS
Plusieurs comptes possibles (séparés par ,) :
Tester :
curl -I https://docs.opsflux.io/ # → 200
curl -I https://docs.opsflux.io/developer/ # → 401 (sans creds)
curl -I -u alice:'Pa$$w0rd' https://docs.opsflux.io/developer/ # → 200
V2 : OpsFlux SSO (forwardAuth — planifié)#
L'auth basique est un placeholder. La cible est d'utiliser le SSO
OpsFlux : un dev se connecte sur app.opsflux.io (idéalement via
GitHub OAuth — voir §7.6) et son cookie/JWT est validé par Traefik via
forwardAuth sur un endpoint backend GET /api/v1/auth/me-cookie.
Pré-requis pour migrer :
- Créer l'endpoint backend
GET /api/v1/auth/me-cookiequi : - Accepte le JWT en cookie
opsflux_session(domaine.opsflux.io) - Retourne 200 si l'utilisateur est admin
- Retourne 401 sinon → Traefik redirige vers
app.opsflux.io/login - Remplacer le middleware
basicauthparforwardauthdansdocker-compose.yml: - Configurer le cookie OpsFlux pour qu'il soit envoyé sur
docs.opsflux.io(domaine.opsflux.io).
7.6 — GitHub OAuth (provider SSO)#
Le backend supporte les providers SSO google_oauth, github_oauth,
azure_ad, okta, keycloak (cf. SSO_PROVIDERS dans
app/api/routes/core/auth.py).
Configurer GitHub OAuth (à faire après le premier boot) :
- Créer une OAuth App GitHub :
- https://github.com/settings/developers →
New OAuth App - Application name :
OpsFlux <ton-domaine> - Homepage URL :
https://app.opsflux.io - Authorization callback URL :
https://api.opsflux.io/api/v1/auth/sso/callback -
Récupérer
Client IDet générer unClient Secret -
Côté OpsFlux (Settings → Intégrations, ou via SQL direct) :
-- À adapter avec ton entity_id
INSERT INTO settings (key, value, scope, scope_id) VALUES
('integration.github_oauth.client_id', '{"v":"<client-id>"}', 'entity', '<entity-id>'),
('integration.github_oauth.client_secret', '{"v":"<client-secret>"}', 'entity', '<entity-id>'),
('integration.github_oauth.scopes', '{"v":"read:user user:email"}', 'entity', '<entity-id>')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
- Vérifier :
GET /api/v1/auth/sso/providersdoit lister{"id":"github_oauth", "name":"GitHub", ...}. Le bouton "Se connecter avec GitHub" apparaît automatiquement sur la page de login.
Limitation à connaître : si l'utilisateur GitHub a son email en
privé, le /user endpoint retourne email: null. Le callback
handler
fallback sur /user/emails (scope user:email) et prend l'email
primary + verified. Si l'utilisateur n'a aucun email vérifié,
le SSO échoue avec ?sso_error=no_email.
8. Premier boot — build + migrations + seed#
cd /opt/opsflux
# Build des images (long — 5 à 15 min selon le VPS)
docker compose build
# Démarrer DB + Redis d'abord, vérifier qu'ils sont healthy
docker compose up -d db redis
docker compose ps # status doit dire "healthy"
# Démarrer le backend (alembic upgrade + seed_i18n + uvicorn)
docker compose up -d backend
docker compose logs -f backend # suivre — Ctrl-C pour quitter le suivi
Au premier boot, vous devez voir :
INFO [alembic.runtime.migration] Running upgrade <0> -> 001_initial...
... (~150 migrations)
[mobile/fr] 360 messages
[app/fr] 5029 messages
✓ Seeded 10807 (key, lang) pairs across 4 languages.
INFO: Uvicorn running on http://0.0.0.0:8000
Puis démarrer le reste :
8.1 — Que se passe-t-il pendant ce premier boot ?#
Le boot du conteneur backend exécute, dans l'ordre :
mkdir -p /opt/opsflux/static/{avatars,attachments} # 1. dossiers static
alembic upgrade head # 2. migrations DB
python -m scripts.seed_i18n # 3. catalogue i18n
uvicorn app.main:app … # 4. lancement uvicorn
# └─ lifespan → seed_production_essentials()
# └─ DEV_SEED_ON_STARTUP → seed_dev_data() (dev only)
# └─ APScheduler + agent loops
Tout est idempotent : un redéploiement sur une DB existante ne duplique rien. Détail de chaque étape ci-dessous.
8.1.1 — Migrations Alembic#
Toute la chaîne 0 → head (~160 migrations actuellement). Crée
schemas, tables, contraintes, indexes, extensions PostgreSQL
(pg_trgm, vector, ltree, postgis — voir
docker/init-extensions.sql). Si une migration échoue le backend
boucle ; voir §13.5.
8.1.2 — Catalogue i18n (FR / EN / ES / PT)#
scripts/seed_i18n.py lit :
- apps/main/src/locales/{fr,en,es,pt}.json (~5 000 clés)
- apps/mobile/src/locales/{fr,en,es,pt}.ts (~360 clés)
- scripts/i18n_seed/{fr,en,es,pt}.json (clés backend / supplémentaires)
→ insère dans i18n_messages (clé, langue, traduction) — environ
10 800 paires au total. Recompute le hash i18n_catalog_meta
par (langue, namespace) pour que le frontend puisse cache-buster.
Idempotent : ne touche pas aux clés existantes. Pour forcer l'overwrite (utile après une mise à jour de fichier locale en prod) :
8.1.3 — Production essentials (seed_production_essentials)#
Tourne à chaque démarrage du backend (lifespan FastAPI). Crée
ce dont l'app a besoin pour fonctionner. Tout est idempotent — chaque
sous-fonction SELECT avant INSERT.
| Étape | Source | Effet |
|---|---|---|
| Entity | env FIRST_ENTITY_* |
1 ligne dans entities (code, name, country, timezone, currency, language='fr', fiscal_year_start=1) |
| Admin user | env FIRST_SUPERUSER + FIRST_SUPERUSER_PASSWORD |
1 ligne dans users (hashé bcrypt). Si le hash existant est corrompu il est ré-écrit. |
| Groupe SUPER_ADMIN | hardcoded | Group "Super Administrators" sur l'entity, role SUPER_ADMIN, admin assigné |
| Workflow definitions | code (_default_workflow_definitions()) |
FSM par défaut pour ADS, AVM, MOC, etc. |
| Dashboard tabs | code (ROLE_TABS) |
~20 onglets par rôle (Mon planning, Mes ADS, Conformité, etc.) |
| Email templates | app/core/email_templates.py (DEFAULT_EMAIL_TEMPLATES) |
Templates Jinja2 pour invitation, reset password, ADS validée, AVM rejetée, etc. |
| PDF templates | app/core/pdf_templates.py (DEFAULT_PDF_TEMPLATES) |
Templates WeasyPrint pour ADS, manifeste cargo, certificat médical, MOC, etc. |
| Reference numbering | code | 13 patterns par module : ADS-{YYYY}-{####}, PRJ-{YY}-{######}, VYG-{YYYY}-{######}, etc. Stockés dans settings scope entity. |
| Dictionary entries | code (~100 catégories) | Listes déroulantes : visa types, vaccine types, passport types, medical checks, relationships, countries, currencies, EPI types, formations, habilitations, statuts métier (paxlog/travelwiz/packlog/planner/projets/MOC), … |
| Compliance matrix | code | 32 positions × 23 types d'habilitations offshore (BOSCO, CARISTE, GRUTIER, HSE, …) |
Conséquence pratique : après le premier boot, un super-admin peut se connecter et tout est utilisable. Pas besoin d'un setup wizard. Les onglets dashboard, les emails de notification, les PDFs générés, les listes de visa/vaccin, les références (ADS-2026-0001) — tout est en place.
Limitation : la langue de l'entity et de l'admin sont hardcodées à
"fr"dans le code. Pour changer, le faire après le boot via la page de profil (admin) ou Settings → Préférences (entity).
8.1.4 — Dev seed (optionnel, dev only)#
Si ENVIRONMENT=development ET DEV_SEED_ON_STARTUP=true, le lifespan
appelle aussi seed_dev_data() qui crée :
- Sample users (operators, managers, viewers — emails dev_*@opsflux.io)
- Sample assets (champs, sites, équipements de démo)
- Sample tiers (entreprises de test)
- Sample projets et missions
Ne tourne JAMAIS en prod, même avec DEV_SEED_ON_STARTUP=true,
parce que la garde settings.is_dev filtre sur ENVIRONMENT. Pour
disposer de données de démo en staging, créer un compose dédié avec
ENVIRONMENT=development.
8.1.5 — Seeds spécialisés (manuels)#
Certains seeds ne tournent pas au boot. À déclencher à la main une fois si nécessaire :
# Dictionnaires asset registry (matériaux pipes, fluides, catégories
# équipements O&G — long, ~5000 entrées) — typiquement on le lance
# une fois après le premier boot.
docker compose exec backend python -m scripts.seed_asset_registry_dictionaries
8.2 — Adapter le seed à votre contexte#
Si vous n'êtes pas une entreprise camerounaise oil & gas, vous voudrez probablement changer les valeurs par défaut avant le premier boot, parce que certaines (entity code, country, currency, timezone) sont ensuite difficiles à changer proprement (FK partout) :
# .env — exemples cohérents
# Compagnie maritime française basée à Marseille
FIRST_ENTITY_CODE=CMA
FIRST_ENTITY_NAME=CMA Logistics
FIRST_ENTITY_COUNTRY=FR
FIRST_ENTITY_TIMEZONE=Europe/Paris
FIRST_ENTITY_CURRENCY=EUR
# Operator pétrolier Émirats
FIRST_ENTITY_CODE=ADNOC
FIRST_ENTITY_NAME=ADNOC Offshore
FIRST_ENTITY_COUNTRY=AE
FIRST_ENTITY_TIMEZONE=Asia/Dubai
FIRST_ENTITY_CURRENCY=AED
⚠ Piège : le seed cherche l'entity par
code IN [FIRST_ENTITY_CODE, "CM", "PER_CMR"]. Si une entity "CM" existe déjà (legacy), il ne créera pas la nouvelle même avecFIRST_ENTITY_CODE=ADNOC. Pour un fresh deploy c'est sans effet ; mais pour un re-seed, supprimer l'entity legacy d'abord ou modifier son code via SQL.
Les autres seeds (dictionnaires, templates, dashboard tabs, compliance matrix) ne sont pas paramétrables via env. Ils sont voulus uniformes parce qu'ils définissent les enums de domaine (un visa "tourist" existe partout, peu importe le pays). Pour les surcharger après le boot :
- Dictionnaires : éditer via Settings → Dictionnaires (UI superadmin) ou directement en SQL sur
dictionary_entries. - Templates email/PDF : Settings → Templates (UI), ou
email_templates/pdf_templates. Le seed met à jour seulement ceux qui ontis_default=true; vos modifs custom (avecis_default=false) sont préservées au redeploy. - Reference numbering : Settings → Numérotation (changer
ADS-{YYYY}-{####}enADS-{prefix}-{YY}{######}par exemple). - Compliance matrix : Settings → Conformité → Matrice habilitations.
8.3 — Re-seeder une étape spécifique#
Si on a fait des bêtises et on veut re-seeder uniquement les dictionnaires sans toucher aux users :
docker compose exec backend python -c "
import asyncio
from app.core.database import async_session_factory
from app.services.core.seed_service import seed_dictionary_entries
async def main():
async with async_session_factory() as s:
await seed_dictionary_entries(s)
await s.commit()
asyncio.run(main())
"
Idem pour seed_email_templates, seed_pdf_templates,
seed_reference_numbering, seed_compliance_matrix,
seed_dashboard_tabs (signatures dans app/services/core/seed_service.py).
9. Vérification post-déploiement#
# Tous les conteneurs UP
docker compose ps
# Endpoints HTTP
for url in https://app.opsflux.io https://api.opsflux.io/api/health \
https://drawio.opsflux.io https://ext.opsflux.io \
https://db.opsflux.io https://docs.opsflux.io \
https://www.opsflux.io ; do
printf '%-40s ' "$url"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 "$url"
done
Attendu :
- app.opsflux.io → 200
- api.opsflux.io/api/health → 200
- drawio.opsflux.io → 200
- ext.opsflux.io → 200 ou 400 (selon présence du token)
- db.opsflux.io → 302 (redirect login pgAdmin)
- docs.opsflux.io → 200
- www.opsflux.io → 200
Si 000 sur tous : Traefik n'écoute pas sur 443 → vérifier
docker logs traefik et que le réseau partagé existe :
docker network ls | grep "${TRAEFIK_NETWORK:-dokploy-network}".
Si 404 sur app. ou api. : le label Traefik n'est pas appliqué
au conteneur → docker inspect <container> | grep traefik doit lister
les labels.
Si TLS error ou cert invalide : ACME n'a pas pu valider, voir les logs Traefik. Causes fréquentes : - DNS pas encore propagé - Port 80 non ouvert (le challenge HTTP-01 passe par 80) - Rate-limit Let's Encrypt (5 certs/semaine/domaine en prod)
10. Connexion initiale + sécurisation#
10.1 — Premier login#
Naviguer sur https://app.opsflux.io :
- Email : FIRST_SUPERUSER (cf. .env)
- Mot de passe : FIRST_SUPERUSER_PASSWORD
Changer le mot de passe immédiatement (Profil → Sécurité).
Ce mot de passe est resté en clair dans .env — il doit être considéré
comme exposé.
10.2 — Activer la MFA#
Profil → Sécurité → MFA → Activer TOTP. Scanner le QR code dans Authy/Google Authenticator/1Password.
10.3 — Créer les premiers utilisateurs#
Module Tiers → Utilisateurs internes → invitation par email
(nécessite SMTP configuré).
10.4 — Verrouiller .env#
11. Sauvegardes#
Trois choses à sauvegarder, par ordre de criticité :
11.1 — DB (CRITIQUE)#
# Dump quotidien (à mettre en cron)
docker compose -f /opt/opsflux/docker-compose.yml exec -T db \
pg_dump -U postgres -Fc opsflux > /backup/opsflux-$(date +%F).dump
# Restore
docker compose exec -T db pg_restore -U postgres -d opsflux -c < backup.dump
Cron /etc/cron.d/opsflux-backup :
0 3 * * * opsflux cd /opt/opsflux && docker compose exec -T db pg_dump -U postgres -Fc opsflux > /backup/opsflux-$(date +\%F).dump && find /backup -name 'opsflux-*.dump' -mtime +14 -delete
11.2 — Volume uploads_data (CRITIQUE)#
docker run --rm \
-v opsflux_uploads_data:/data:ro \
-v /backup:/backup \
alpine tar czf /backup/uploads-$(date +%F).tar.gz -C /data .
Mieux : passer
STORAGE_BACKEND=s3et déléguer la durabilité à votre fournisseur S3.
11.3 — .env (CRITIQUE — sans, pas de restore possible)#
11.4 — Off-site#
rsync ou restic ou borg vers une destination distincte du VPS.
Tester un restore au moins une fois par trimestre.
12. Mises à jour#
12.1 — Mise à jour standard#
cd /opt/opsflux
git pull origin main
docker compose build backend frontend
docker compose up -d backend frontend
docker compose logs -f backend | head -100 # vérifier alembic + boot
Migrations DB :
alembic upgrade headtourne automatiquement au démarrage du backend. Si elle échoue, le conteneur boucle en redémarrage — voir §13.
12.2 — Mise à jour avec breaking change#
Lire le CHANGELOG.md du repo (à venir) avant. Si la release introduit
une migration destructive (DROP COLUMN, …) :
# 1. Backup AVANT
docker compose exec -T db pg_dump -U postgres -Fc opsflux > /backup/pre-upgrade.dump
# 2. Pull + build
git pull origin main && docker compose build
# 3. Stop tout sauf db
docker compose stop backend frontend ext-paxlog
# 4. Restart backend (applique les migrations)
docker compose up -d backend
docker compose logs -f backend
# 5. Si OK : restart le reste
docker compose up -d frontend ext-paxlog
12.3 — Rollback#
git checkout <commit-sha-précédent>
docker compose build backend frontend
docker compose up -d backend frontend
⚠ Si la migration vers la nouvelle version a déjà tourné, le rollback du code peut laisser la DB en avance. Voir §13.
13. Recovery — situations classiques#
13.1 — Backend en boucle de restart#
Cas fréquents :
a) Can't locate revision identified by 'XXX'#
La DB pointe sur une migration qui n'existe plus dans le code (revert de migration). Solution :
# Voir l'état actuel
docker compose exec db psql -U postgres -d opsflux -c \
"SELECT version_num FROM alembic_version;"
# Lister les migrations connues du code
docker compose exec backend alembic history | head -20
# Stamper la DB sur la dernière révision présente dans le code (CARE!)
docker compose exec backend alembic stamp <revision-id-existante>
# Si la migration manquante avait fait un DROP COLUMN, recréer la
# colonne à la main pour matcher le modèle SQLAlchemy reverté.
b) connection refused vers db:5432#
La DB n'est pas encore healthy. Vérifier docker compose ps —
attendre le (healthy).
c) password authentication failed#
POSTGRES_PASSWORD dans .env ne matche pas ce qui est dans le
volume pg_data. Si vous avez changé le mot de passe après le
premier boot, le volume garde l'ancien. Deux options :
- Reset le mot de passe via psql :
ALTER USER postgres WITH PASSWORD '<nouveau>';
- Ou détruire le volume (perte de données !) :
docker compose down -v
13.2 — Traefik exited / certificats illisibles#
Si failed to set up container networking: network XXX not found :
le réseau Docker a disparu (typique après un redémarrage du démon).
Recréer :
docker network create "${TRAEFIK_NETWORK:-dokploy-network}"
docker start traefik
docker compose -f /opt/opsflux/docker-compose.yml up -d
13.3 — Rate-limit Let's Encrypt#
429 :: urn:ietf:params:acme:error:rateLimited dans les logs Traefik.
Vous avez fait > 5 demandes/semaine pour le même domaine. Solution :
attendre 7 jours OU utiliser le staging Let's Encrypt en attendant
(--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory).
13.4 — Disque plein#
docker system df # voir ce qui prend de la place
docker image prune -a # nettoie les vieilles images
docker volume ls # repérer les volumes orphelins
docker volume prune # CAREFUL — vérifier d'abord
13.5 — Migration alembic qui plante sur DB fresh#
Si le backend boucle avec un message du genre :
C'est qu'une migration de "fix" assume un état d'une DB plus ancienne
qui n'existe pas sur une fresh DB. Cas vu en pratique :
135_moc_fix_soft_delete qui renommait archived_at → deleted_at,
mais 134 a depuis été corrigé pour créer deleted_at directement →
fresh DB n'a jamais eu archived_at. Fix appliqué dans le commit
b86bd0f1 — vérifié sur fresh DB le 2026-04-30 : la chaîne 0 → 159
passe maintenant proprement.
Fix générique : rendre la migration idempotente avec une vérif
d'existence dans information_schema.columns :
def _column_exists(table: str, column: str) -> bool:
bind = op.get_bind()
return bool(bind.execute(sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first())
def upgrade() -> None:
if _column_exists("mocs", "archived_at"):
op.alter_column("mocs", "archived_at", new_column_name="deleted_at")
# else: déjà bon
Sympôme caché : APScheduler peut sembler tourner correctement dans les logs (
travelwiz_pickup_reminders: 0 reminders sent) après que uvicorn ait fini par démarrer entre deux retries. Toujours regarderdocker logs <backend> 2>&1 | grep -E "FAILED|ERROR.*alembic"AVANT de conclure que tout va bien.
13.6 — Récupérer des logs anciens#
Logs JSON de chaque conteneur dans /var/lib/docker/containers/<id>/<id>-json.log.
Configurer la rotation dans /etc/docker/daemon.json :
Puis systemctl restart docker.
14. Déployer avec un control plane (Dokploy / Coolify / EasyPanel / Caprover / Portainer / …)#
Le compose OpsFlux ne dépend de rien de spécifique à un orchestrateur :
il consomme un fichier docker-compose.yml standard. Tout control plane
qui sait avaler ça peut le déployer. Ce qu'il te faut :
- Une instance Traefik (v2 ou v3) déjà en place — la plupart des control planes en fournissent une nativement.
- Un réseau Docker partagé entre Traefik et OpsFlux. Par défaut le
compose attend
dokploy-network; surcharger via la variableTRAEFIK_NETWORKdans.envselon ta plateforme :
| Plateforme | TRAEFIK_NETWORK= |
|---|---|
| Dokploy | dokploy-network (défaut) |
| Coolify | coolify |
| Caprover | captain-overlay-network |
| EasyPanel | easypanel |
| Portainer + Traefik | dépend de comment tu as nommé le network Traefik |
| Traefik standalone (§7.1) | proxy (ou ce que tu as mis dans le compose Traefik) |
| Vanilla docker compose, pas de Traefik | tu dois en monter un d'abord — voir §7.1 |
-
Un cert resolver ACME nommé
letsencryptcôté Traefik (la plupart des control planes le configurent par défaut). Si tu utilises un nom différent ou un wildcard, voir §7.4. -
Définir le
.envavec au minimumPOSTGRES_PASSWORD,SECRET_KEY,JWT_SECRET_KEY,ENCRYPTION_KEY,DOMAIN(cf. §6).
14.0 — Recettes par plateforme (vue d'ensemble)#
Dokploy#
Project → New Compose
Source = GitHub (this repo)
Branch = main
Compose path = ./docker-compose.yml
Auto-deploy = on
Environment = paste your full .env content
→ Deploy
dokploy-network est créé automatiquement à l'install Dokploy.
Traefik est inclus, ACME letsencrypt (HTTP-01) configuré par défaut.
Coolify#
+ New Resource → Docker Compose
Source = Public/Private Repository (this repo)
Branch = main
Build Pack = Docker Compose
Compose = docker-compose.yml
Network = coolify (default), or create your own
Env Vars = paste .env, set TRAEFIK_NETWORK=coolify
→ Deploy
EasyPanel#
Create Service → Compose
Source = Git → this repo
Compose path = docker-compose.yml
Env: TRAEFIK_NETWORK=easypanel + .env content
→ Deploy
Caprover#
Pas idéal pour un compose multi-services comme celui-ci — Caprover préfère un service par "app". Mais possible via "One Click Apps" custom :
Apps → Add → One Click App → Custom YAML
Paste docker-compose.yml content
Set TRAEFIK_NETWORK=captain-overlay-network
Portainer#
Stacks → Add stack
Build method = Repository
Compose path = docker-compose.yml
Env vars = paste .env, set TRAEFIK_NETWORK=<ton-network-traefik>
→ Deploy the stack
Vanilla docker compose (pas de control plane)#
Voir §3 à §13 ci-dessus — c'est le chemin recommandé pour le contrôle
total. TRAEFIK_NETWORK=proxy (ou le nom que tu as choisi en §7.1).
14.1 — Triggers via API Dokploy#
# Trigger deploy
curl -X POST "$API_DOKPLOY_URL/compose.deploy" \
-H "x-api-key: $API_DOKPLOY" \
-H "Content-Type: application/json" \
-d "{\"composeId\":\"$DOKPLOY_COMPOSE_ID\"}"
# Status (retourne le compose complet — extraire .composeStatus)
curl "$API_DOKPLOY_URL/compose.one?composeId=$DOKPLOY_COMPOSE_ID" \
-H "x-api-key: $API_DOKPLOY"
# composeStatus: idle | running | done | error
14.2 — Créer un compose programmatiquement (instance de test)#
Workflow complet en 4 appels API. Utile pour scripter une instance de staging/test sans passer par l'UI Dokploy :
TOKEN=$API_DOKPLOY
# 1. Créer le compose vide dans un environnement existant
COMPOSE_ID=$(curl -sX POST "$API_DOKPLOY_URL/compose.create" \
-H "x-api-key: $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"OPSFLUX-TEST","environmentId":"<env-id-existant>"}' \
| grep -oE '"composeId":"[^"]+"' | cut -d'"' -f4)
# 2. Configurer la source GitHub
curl -sX POST "$API_DOKPLOY_URL/compose.update" \
-H "x-api-key: $TOKEN" -H "Content-Type: application/json" \
-d "{
\"composeId\":\"$COMPOSE_ID\",
\"sourceType\":\"github\",
\"githubId\":\"<github-credential-id>\",
\"repository\":\"OPSFLUX\",
\"owner\":\"hmunyeku\",
\"branch\":\"main\",
\"composePath\":\"./docker-compose.yml\",
\"composeType\":\"docker-compose\",
\"autoDeploy\":false
}"
# 3. Pousser le .env (un seul gros string)
ENV_CONTENT=$(cat .env)
node -e "console.log(JSON.stringify({composeId:'$COMPOSE_ID',env:require('fs').readFileSync('.env','utf8')}))" \
| curl -sX POST "$API_DOKPLOY_URL/compose.saveEnvironment" \
-H "x-api-key: $TOKEN" -H "Content-Type: application/json" --data-binary @-
# 4. Déployer
curl -sX POST "$API_DOKPLOY_URL/compose.deploy" \
-H "x-api-key: $TOKEN" -H "Content-Type: application/json" \
-d "{\"composeId\":\"$COMPOSE_ID\"}"
# Pour tear-down :
curl -sX POST "$API_DOKPLOY_URL/compose.stop" -H "x-api-key: $TOKEN" -d "{\"composeId\":\"$COMPOSE_ID\"}"
curl -sX POST "$API_DOKPLOY_URL/compose.delete" -H "x-api-key: $TOKEN" -d "{\"composeId\":\"$COMPOSE_ID\"}"
# `delete` ne supprime PAS les volumes Docker — nettoyer manuellement :
ssh root@<vps> "docker volume rm \$(docker volume ls -q | grep <appName-prefix>)"
composeStatus=done≠ application healthy. Dokploy considère le deploy "fini" dès quedocker compose uprend la main, sans attendre que les conteneurs deviennent healthy. Toujours vérifier après un deploy :docker ps --filter name=<appName>puis tail des logs.
14.3 — Conflit Traefik si vous déployez plusieurs instances OpsFlux#
⚠️ Quand deux compose projects OpsFlux partagent la même Traefik
(prod + staging sur le même VPS Dokploy/Coolify/EasyPanel/Caprover/…,
ou n'importe quel setup multi-instance derrière un Traefik commun),
ils peuvent se battre pour les mêmes noms de routers Traefik. Avant
le fix STACK_NAME, le compose hardcodait des noms comme
opsflux-app-web, opsflux-api-web, pgadmin-web, vitrine, etc.
Deux compose les déclaraient en parallèle → Traefik refuse :
ERR Router defined multiple times with different configurations
ERR Could not define the service name for the router: too many services
routerName=opsflux-app-web
Symptômes (avant le fix STACK_NAME, observés le 2026-04-30 avec
deux compose OPSFLUX + OPSFLUX-TEST sur le même Traefik Dokploy) :
| Service | Comportement de la 2e instance |
|---|---|
Frontend (app.<DOMAIN>) |
⚠ HTTP 200 — nginx statique sert le SPA quel que soit le Host header, donc même si Traefik route vers le mauvais conteneur, l'utilisateur final voit du contenu cohérent. Faux positif dangereux. |
Backend (api.<DOMAIN>) |
❌ HTTP 404 — Traefik route vers le backend prod, qui rejette le Host inconnu |
Drawio (drawio.<DOMAIN>) |
❌ HTTP 404 — pareil |
pgAdmin (db.<DOMAIN>) |
❌ Routage non-déterministe → DB potentiellement écrasée si on se connecte au mauvais |
| Spam Traefik | ~200 lignes ERR/min dans les logs |
Après application de la solution #1 (STACK_NAME=opsflux-test3),
même setup, deux compose côte à côte sur le même Traefik :
| Service | 2e instance |
|---|---|
app.<test-DOMAIN> |
✅ 200 |
api.<test-DOMAIN>/api/health |
✅ 200 |
drawio.<test-DOMAIN> |
✅ 200 |
ext.<test-DOMAIN> |
✅ 200 |
| Conflits Traefik OpsFlux | 0 |
| Prod inchangée | ✅ tous endpoints 200 |
Solutions :
- Variable
STACK_NAME(recommandé, intégré depuis le commit suivant) — ledocker-compose.ymltemplate désormais tous les noms de routers/services/middlewares Traefik avec${STACK_NAME:-opsflux}-*. Pour faire cohabiter plusieurs instances :
# .env de l'instance prod
STACK_NAME=opsflux # défaut, rétro-compatible
DOMAIN=opsflux.io
# .env de l'instance staging (sur le même Traefik)
STACK_NAME=opsflux-staging
DOMAIN=staging.opsflux.io
Tous les noms de routers deviennent opsflux-staging-api-web,
opsflux-staging-app-web, etc. → zéro collision côté Traefik. Les
Host() rules continuent à isoler le routage par domaine.
- Une seule instance par Traefik — toujours valable si vous préférez l'isolation hard. Utiliser un VPS dédié (option A §7.1) pour le staging.
- Isoler Dokploy — créer un projet Dokploy séparé avec son propre
Traefik (Dokploy v0.21+ supporte plusieurs Traefik via le champ
serverId). Pertinent si on veut aussi isoler les certs ACME.
14.4 — Pièges spécifiques par plateforme#
Dokploy#
- Ne JAMAIS lancer
docker runen parallèle d'un compose Dokploy pour le même service : Dokploy le supprime au prochain deploy.scripts/deploy-vps.shdocumente ce piège historique. - Le redéploiement Dokploy supprime parfois le réseau Docker
dokploy-networket Traefik s'arrête avecfailed to set up container networking. Si vos endpoints retournent HTTP 000 après un deploy, vérifier :docker ps | grep traefik— restart manuellement avecdocker network create dokploy-network && docker start traefik. compose.deletene supprime PAS les volumes Docker associés. Faire undocker volume ls | grep <appName>puisdocker volume rmà la main si vous voulez vraiment nettoyer.
Coolify#
- Coolify renomme le réseau interne par projet (
coolify-<uuid>). Le réseau partagé avec Traefik s'appellecoolifypar défaut. Bien mettreTRAEFIK_NETWORK=coolifysinon les conteneurs OpsFlux démarrent mais Traefik ne les voit pas. - Coolify v4 supporte les compose multi-services mais peut être tatillon
sur les
depends_on: condition: service_healthy. Si un service reste enPending, vérifier dans la timeline Coolify quel conteneur attend une healthcheck qui ne vient pas.
EasyPanel / Caprover / Portainer#
- Pas de spécificités majeures, mais ces plateformes proxy souvent les
logs Traefik via leur propre stdout — utiliser leur UI pour les voir
plutôt que
docker logs traefikdirect. - Caprover en mode Swarm peut nécessiter un
mode: replicatedexplicite sur certains services. Ce compose ne le déclare pas (Caprover utilise son défaut, ce qui marche pour 99% des cas).
15. Annexes#
15.1 — Compose dev local (sans Traefik)#
docker-compose.dev.yml est fourni à la racine du repo. Il expose les
ports en local (localhost:8000, localhost:5173) sans HTTPS, et
utilise mailhog pour intercepter les emails.
docker compose -f docker-compose.dev.yml up
# Backend : http://localhost:8000/docs
# Frontend : http://localhost:5173
# Mailhog : http://localhost:8025
15.2 — Agent worker pool (optionnel)#
Pour activer l'agent IA support (auto-fix de tickets), déployer
agent-worker/docker-compose.yml comme un second projet compose :
cd /opt/opsflux/agent-worker
# Créer un .env avec les MÊMES POSTGRES_*, ENCRYPTION_KEY,
# OPSFLUX_INTERNAL_TOKEN que dans le .env principal
docker compose up -d
Voir ../agent-worker/README.md pour le détail.
15.3 — Restauration complète depuis backup#
# 1. Préparer le serveur (étapes 3 + 4)
# 2. Cloner le repo (étape 5)
# 3. Restaurer .env
gpg -d /backup/.env.gpg > /opt/opsflux/.env
chmod 600 /opt/opsflux/.env
# 4. Démarrer DB seul
docker compose up -d db
sleep 10
# 5. Restaurer le dump
cat /backup/opsflux-2026-04-30.dump | \
docker compose exec -T db pg_restore -U postgres -d opsflux -c --if-exists
# 6. Restaurer les uploads
docker run --rm \
-v opsflux_uploads_data:/data \
-v /backup:/backup \
alpine tar xzf /backup/uploads-2026-04-30.tar.gz -C /data
# 7. Démarrer le reste
docker compose up -d
15.4 — Variables d'environnement — référence complète#
Voir ../.env.example.
15.5 — Voir aussi#
STACK.md— architecture & schéma logique../CLAUDE.md— conventions codeadr/— décisions d'architecturecheck/00_PROJECT.md— cahier des charges fonctionnel
En cas de blocage#
- Lire les logs :
docker compose logs --tail 100 <service> - Vérifier l'état complet :
docker compose ps && docker network ls - Tester la connectivité interne :
docker compose exec backend curl -fsS http://db:5432(doit échouer en HTTP mais prouver le DNS) - Ouvrir une issue GitHub avec les logs anonymisés (sans
.env).