# 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). **État : stack prod construite et démarrée le 2026-05-12. Cutover NPM encore à faire (cf. § « Étape manuelle restante »).** --- ## É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` : - dev : 3001 (frontend Vite) + 8001 (backend granian) — séparés car HMR - prod : **3002** uniquement (Reflex 0.9+ exige frontend + backend même port en prod ; granian sert tout depuis 3002, y compris `/_event` en WS) - Projets compose nommés : `eptm-dashboard-dev` et `eptm-dashboard-prod` (pour éviter que `compose up` d'une stack recrée le container de l'autre). - Aucun port host exposé — tout passe par NPM sur `proxy_net`. - NPM continue de gérer les certs Let's Encrypt (déjà émis pour ce sous-domaine, juste à conserver — pas de renouvellement à forcer). --- ## ✅ Décisions prises (2026-05-12) | # | Question | Hypothèse par défaut | |---|---|---| | # | Question | Décision | |---|---|---| | 1 | Données prod | **Isolées** : `./data_prod/` séparé de `./data/` | | 2 | DB initiale prod | **Copie** de `data/absences.db` au cutover | | 3 | Streamlit après cutover | **Disabled** ~1 mois (rollback plan B), purge en tâche TODO | | 4 | Build | **Local** sur le serveur (pas de CI / registry pour démarrer) | | 5 | Anciens `Dockerfile` + `docker-compose.yml` | **Renommés `.back`**, nouveaux fichiers `.prod` créés à côté | | 6 | `.env.prod` | **Minimal** : `REFLEX_SECRET_KEY` + Telegram bot + `TZ`. SMTP/Escada/etc. restent dans `settings.json` | | 7 | Emplacement `data_prod/` | `/opt/eptm-dashboard/data_prod/` (gitignored) | | 8 | `browser_profile/` côté prod | **Non copié** — session SSO à refaire au 1er sync (Chromium non-headless une fois) | | 9 | Templates AcroForm + docs `.md` | **Déménagés** depuis `data/` vers le repo (`./templates/` et `./docs/`) → propagés par `COPY .` du Dockerfile, plus besoin de sync manuel | | 10 | Crontab prod | **Séparée** de la dev (lignes distinctes pour les deux containers) | --- ## Étape manuelle restante : cutover NPM + Streamlit L'image prod est buildée, le container `eptm-dashboard-prod-app-1` tourne sur `proxy_net:3002`. Pour basculer `dashboard.eptm-automation.ch` : ### 1. Reconfigurer NPM (UI sur `https://npm.eptm-automation.ch:81`) Sur le **proxy host** existant `dashboard.eptm-automation.ch` (qui pointe actuellement sur Streamlit `172.17.0.1:8501`) : - **Forward Hostname / IP** : `eptm-dashboard-prod-app-1` - **Forward Port** : `3002` - **Cache Assets** : décoché (Reflex bundle Vite est déjà optimisé) - **Block Common Exploits** : OK - **Websockets Support** : ✅ **coché** (indispensable — Reflex utilise WS) - **Onglet SSL** : conserver le certificat Let's Encrypt déjà émis, Force SSL ON, HSTS ON, HTTP/2 ON > ⚠️ Pas besoin de custom location `/_event` cette fois (à la différence du > dev) : en prod le backend et le frontend sont sur le même port (3002). ### 2. Stopper Streamlit ```bash sudo systemctl stop absences sudo systemctl disable absences # rollback toujours possible avec `enable + start` ``` ### 3. Vérifier ```bash curl -fsS -o /dev/null -w "HTTP %{http_code}\n" https://dashboard.eptm-automation.ch/ # → 200 attendu, server: granian dans les headers ``` ### 4. Ajouter une ligne crontab pour les cron_tick prod ```bash crontab -e # Ajouter : * * * * * docker exec eptm-dashboard-prod-app-1 python /app/scripts/cron_tick.py >> /var/log/cron-prod.log 2>&1 ``` (La ligne dev existante reste — les deux containers exécutent leur propre cron_tick avec leur propre table `cron_jobs`.) --- ## Fichiers créés (déjà en place) ### 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) ```