# Déploiement en production — EPTM Dashboard Procédure de bascule de l'app **Streamlit legacy** (`absences.service`, `dashboard.eptm-automation.ch`) vers l'app **Reflex** (en prod sur le même sous-domaine). Document à retravailler avant exécution. --- ## État actuel (2026-05-11) ``` Internet :80/:443 │ ▼ ┌────────────────────────────────────┐ │ NPM (nginx-proxy-manager) │ │ container "npm" sur proxy_net │ └────────────────────────────────────┘ │ ├── dashboard.eptm-automation.ch │ → 172.17.0.1:8501 │ → Streamlit legacy (systemd "absences.service") │ /opt/absences/.venv/bin/streamlit run src/app.py │ └── dev.dashboard.eptm-automation.ch → eptm-dashboard-app-1:3001 (Reflex dev) + /_event → :8001 (network proxy_net) ``` - Streamlit tourne en systemd `absences.service` (enabled, depuis le 2026-05-09). - L'app Reflex dev est containerisée, déjà sur `proxy_net`, accessible via NPM. - Pas encore de Dockerfile/compose **prod** : il faut les créer. ## État cible ``` Internet :80/:443 → NPM │ ├── dashboard.eptm-automation.ch │ → eptm-dashboard-prod-app-1:3002 (Reflex prod) │ + /_event → :8002 │ └── dev.dashboard.eptm-automation.ch → eptm-dashboard-app-1:3001 (Reflex dev, inchangé) + /_event → :8001 ``` - Streamlit `absences.service` arrêté + disabled (binaires gardés en backup quelques semaines, suppression plus tard). - Deux stacks compose côte à côte sur `proxy_net`, ports internes distincts (3001/8001 dev, 3002/8002 prod), aucun port host exposé. - NPM continue de gérer les certs Let's Encrypt (déjà émis pour ce sous-domaine, juste à conserver — pas de renouvellement à forcer). --- ## ⚠️ À clarifier avant exécution | # | Question | Hypothèse par défaut | |---|---|---| | 1 | **Données** : prod partage `./data` avec dev ou stack séparée `./data-prod` ? | **Séparer** (`./data-prod`) — sinon un test en dev peut planter la prod (WAL OK mais migrations destructives non) | | 2 | **Premier remplissage de la DB prod** : repartir d'un import frais (run_imports + sync_escada) ou copier `data/eptm.db` actuel ? | **Copier** la DB de dev au moment du cutover (snapshot cohérent) | | 3 | **Streamlit après cutover** : on garde le service en `disabled` qq semaines, ou on purge `/opt/absences` ? | **Garder disabled** ~1 mois (rollback plan B), purge en tâche TODO | | 4 | **Build** : local sur le serveur ou registry (GHCR / Docker Hub) ? | **Local** sur le serveur — pas de CI pour démarrer, on n'est qu'une instance | | 5 | **Downtime acceptable** au cutover ? | ~30-60s pendant que NPM bascule de proxy host. Pas de zero-downtime | | 6 | **`.env.prod`** : tester avec env identique à dev ? | À auditer ensemble (peut contenir `escada_username/password`, SMTP, etc.) | --- ## Fichiers à créer ### 1. `Dockerfile.prod` (multi-stage) ```dockerfile # Stage 1 : builder — installe deps + export frontend FROM python:3.13-slim AS builder WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ nodejs npm curl unzip && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN reflex export --frontend-only --no-zip # Stage 2 : runtime — backend granian uniquement FROM python:3.13-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=builder /app /app RUN pip install --no-cache-dir -r requirements.txt ENV FRONTEND_PORT=3002 BACKEND_PORT=8002 EXPOSE 3002 8002 CMD ["reflex", "run", "--env", "prod"] ``` > NB : à vérifier — Reflex 0.9.x peut exiger `reflex run --backend-only` côté > runtime si le frontend export est servi par le même process. **TODO : > tester avec un build pilote avant cutover.** ### 2. `docker-compose.prod.yml` ```yaml services: app: build: context: . dockerfile: Dockerfile.prod image: eptm-dashboard-prod container_name: eptm-dashboard-prod-app-1 init: true restart: unless-stopped volumes: - ./data-prod:/app/data # ← séparé de dev (cf. Q1) - ./logs-prod:/logs env_file: - .env.prod environment: - FRONTEND_PORT=3002 - BACKEND_PORT=8002 - API_URL=https://dashboard.eptm-automation.ch - REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data - TZ=Europe/Zurich networks: - default - proxy_net networks: proxy_net: external: true ``` ### 3. `.env.prod` À auditer — copier les valeurs sensibles depuis le `settings.json` plutôt que de tout mettre en env (séparation cleane). Variables minimales : - `DATA_DIR=/app/data` (déjà override probable) - `(à voir avec l'user — TZ, secrets, etc.)` --- ## Procédure de cutover (jour J) ### J−1 (préparation) 1. **Backup DB Streamlit** (si pertinent — vérifier si Streamlit a sa propre DB) : ```bash sudo cp /opt/absences/data /opt/backups/absences-$(date +%F).tgz # à adapter ``` 2. **Backup DB Reflex dev** (qui deviendra la prod) : ```bash cp /opt/eptm-dashboard/data/eptm.db /opt/backups/eptm-pre-cutover-$(date +%F).db ``` 3. Créer `Dockerfile.prod`, `docker-compose.prod.yml`, `.env.prod` (cf. ci-dessus). 4. Préparer `./data-prod/` (copie de `./data/` au moment opportun). ### J0 (cutover, ~10 min de fenêtre) ```bash # 1. Build l'image prod (peut être fait avant la fenêtre — pas de downtime) cd /opt/eptm-dashboard docker compose -f docker-compose.prod.yml build app # 2. Snapshot DB de dev → data-prod cp -r data/ data-prod/ # (optionnel : purger data-prod/browser_profile et data-prod/pdfs si volumineux # et resync-able depuis Escada) # 3. Démarrer le container prod (encore inaccessible — NPM pointe encore sur Streamlit) docker compose -f docker-compose.prod.yml up -d app docker logs -f eptm-dashboard-prod-app-1 # vérifier "App running" puis Ctrl-C # 4. Stopper Streamlit sudo systemctl stop absences sudo systemctl disable absences # 5. Reconfigurer NPM proxy host #2 (dashboard.eptm-automation.ch) # via UI https://npm.eptm-automation.ch : # - Forward Hostname / IP : eptm-dashboard-prod-app-1 # - Forward Port : 3002 # - WebSocket support : ON # - Custom location : # Location : /_event # Forward : eptm-dashboard-prod-app-1:8002 # Advanced : proxy_read_timeout 86400; # - SSL : conserver le certificat Let's Encrypt déjà émis pour # ce domaine, Force SSL, HSTS, HTTP/2 # 6. Recharger NPM si l'UI ne le fait pas auto : docker exec npm nginx -s reload # 7. Vérification curl -I https://dashboard.eptm-automation.ch # → doit retourner 200 + headers Reflex (Server: granian) ``` ### J+1 → J+30 (stabilisation) - Garder `absences.service` en `disabled` (rollback rapide possible). - Surveiller `docker logs -f eptm-dashboard-prod-app-1` + `data-prod/logs/`. - Si stable après ~1 mois : purger `/opt/absences/`, retirer le user systemd file. --- ## Rollback (si quelque chose plante après le cutover) ```bash # 1. Rebasculer NPM sur Streamlit # UI NPM → proxy host #2 → Forward : 172.17.0.1:8501 # 2. Relancer Streamlit sudo systemctl start absences # 3. Stopper le container prod Reflex docker compose -f docker-compose.prod.yml down # 4. Investiguer les logs Reflex tranquillement docker logs eptm-dashboard-prod-app-1 > /tmp/cutover-fail.log ``` --- ## Workflow déploiements suivants Une fois la prod en place : ```bash # 1. Dev : commit & push git add -A && git commit -m "feat: xxx" && git push # 2. Build nouvelle image prod cd /opt/eptm-dashboard docker compose -f docker-compose.prod.yml build app # 3. Redémarrer le container (downtime ~10s) docker compose -f docker-compose.prod.yml up -d app # 4. Vérifier docker logs -f eptm-dashboard-prod-app-1 curl -I https://dashboard.eptm-automation.ch ``` Optionnel : tagger les versions prod (`git tag -a prod-2026-05-15 && git push --tags`). --- ## Caveats / pièges à surveiller - **WebSocket NPM** : déjà OK en dev (cf. memory NPM). Reproduire **exactement** la même config sur proxy host #2 : WS support cocheé + custom location `/_event` avec `proxy_read_timeout 86400`. Sans ça, le state Reflex ne fonctionne pas. - **`API_URL`** : doit matcher l'URL publique exacte (`https://dashboard.eptm-automation.ch`), sinon le frontend ne joint pas le backend. - **Ports internes prod** : `3002`/`8002` (pas `3001`/`8001` qui sont pris par dev). - **`reflex export`** prend 1-5 min (npm install + bundle). À faire **avant** la fenêtre de cutover. - **`data-prod/browser_profile/`** : éviter de copier — le profil Chrome contient une session SSO valide pour le compte de dev. La prod doit re-login au premier sync Escada (ouvrir un Chromium visible avec `headless=False` une fois, ou pré-importer le profile sur un compte service dédié). - **localStorage des users connectés** : le LocalStorage du browser des utilisateurs survit au cutover (clés `username`, `theme`, etc.) — ils ne seront pas déconnectés. - **DNS / certs** : `dashboard.eptm-automation.ch` pointe déjà sur l'IP publique du serveur, le cert NPM est déjà émis et auto-renouvelé. Pas d'action DNS/cert nécessaire. - **Backups DB** : ajouter un cron quotidien après cutover : ```cron 0 3 * * * cp /opt/eptm-dashboard/data-prod/eptm.db /opt/backups/eptm-$(date +\%F).db ``` --- ## Annexe : commandes de check post-déploiement ```bash # Container prod up ? docker ps --filter name=eptm-dashboard-prod-app-1 # Logs récents docker logs --tail 50 eptm-dashboard-prod-app-1 # Proxy NPM répond ? curl -fsS -o /dev/null -w "HTTP %{http_code}\n" https://dashboard.eptm-automation.ch/login # WS handshake OK ? curl -fsS -o /dev/null -w "HTTP %{http_code}\n" \ -H "Upgrade: websocket" -H "Connection: Upgrade" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ -H "Sec-WebSocket-Version: 13" \ https://dashboard.eptm-automation.ch/_event # → attend HTTP 101 (Switching Protocols) ```