feat: package the application for Docker, with deployment docs

Multi-stage build on node:22-alpine, standalone output, non-root user,
healthcheck on /api/health, and migrations applied by the entrypoint
before the first request. A failed migration stops the container rather
than serving an inconsistent database.

Getting the Prisma CLI into the runtime image took three attempts and
the reasoning is recorded in the Dockerfile. Copying it out of the build
stage leaves its transitive dependencies behind; patching them in one at
a time is a losing game. It now gets its own stage and its own tree,
with the schema and prisma.config.ts beside it, and the entrypoint runs
from there so every import resolves locally. The version is read from
our own package.json so it cannot drift from the generated client.

Two things had to change to build without a database, which a build
container rightly does not have. prisma.config.ts no longer reads the
URL through prisma's env() helper, which throws on a missing variable
even for `generate`. And lib/db.ts creates the client on first use
rather than on import: Next imports every route module while collecting
page data, so a module that threw on import failed the build with an
error naming whichever route was analysed first, which says nothing
useful. The failure now lands on the first query, where it belongs.

Verified by running the image against a real database: migrations
applied, cron scheduled in Europe/Zurich, a device paired, and the panel
image served as a genuine 1-bit 800x480 BMP — so satori, resvg and the
vendored fonts all work on musl. The image hash came out identical to
the one produced on the glibc host, which is the reproducibility the
vendored fonts were for.

The production overlay publishes through an existing Traefik, drops the
host port, mounts the filesystem read-only, and adds a nightly dump kept
for a fortnight.

README and DEPLOY are in French and cover what actually bites: the panel
receives nothing and only updates when it wakes; the issuer must match
to the character; the captive portal URL takes no trailing slash; a
rollback across a migration needs the dump, because Prisma does not
undo one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-20 22:48:36 +02:00
co-authored by Claude Opus 5
parent ed89d08172
commit f89e4690ba
13 changed files with 619 additions and 154 deletions
+124
View File
@@ -0,0 +1,124 @@
# Déploiement
Procédures d'exploitation pour `horaires.ita-ito.com`. Le [README](README.md)
couvre l'installation locale et la configuration d'Authentik.
## Première installation sur le VPS
Prérequis : Docker, Docker Compose, et un Traefik déjà en route possédant le
réseau externe nommé par `TRAEFIK_NETWORK`.
```bash
git clone <dépôt> /opt/horaires && cd /opt/horaires
cp .env.example .env
sed -i "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 33)|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -base64 24)|" .env
# Renseignez APP_DOMAIN, AUTH_URL, AUTH_AUTHENTIK_*, TRANSLATION_API_KEY.
mkdir -p backups
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
```
Les migrations s'appliquent au démarrage. Si elles échouent, le conteneur
s'arrête **avant** de servir quoi que ce soit, plutôt que d'exposer une base
incohérente : `docker compose logs app` dira pourquoi.
Ensuite :
```bash
docker compose exec app node .migrator/node_modules/prisma/build/index.js migrate status
curl -fsS https://horaires.ita-ito.com/api/health
```
Puis, une fois connecté : *Paramètres* pour le canton et les intervalles de
réveil, *Fériés* → **Synchroniser maintenant**, *Horaires* pour la vraie
semaine, et enfin l'appairage de l'écran (README).
## Mise à jour
```bash
cd /opt/horaires
./scripts/backup.sh # ou attendre le dump nocturne
git pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
docker compose logs -f app # ^C une fois « démarrage de l'application »
```
`--wait` rend la main seulement quand le *healthcheck* passe. S'il ne passe pas,
la mise à jour a échoué et l'ancien conteneur a déjà été remplacé : voir le
retour arrière ci-dessous.
**Avant une mise à jour qui touche au rendu** (polices, gabarit, logo),
sachez que le nom de fichier de l'image change et que tous les écrans
redessineront une fois. C'est sans gravité, seulement un cycle de batterie.
## Sauvegarde
Le service `backup` dépose un dump compressé par jour dans `./backups` et purge
au-delà de `BACKUP_KEEP_DAYS` (14 par défaut).
Dump immédiat :
```bash
docker compose exec -T db pg_dump -U horaires horaires | gzip > backups/horaires-$(date +%Y%m%d-%H%M%S).sql.gz
```
Ces fichiers contiennent les horaires, les messages et le journal d'audit. Ils
ne contiennent **aucun secret** : les jetons d'appareil n'y figurent que sous
forme d'empreintes, et les identifiants Authentik vivent dans `.env`, qui n'est
pas dans les sauvegardes. Sauvegardez `.env` séparément, ailleurs.
## Restauration
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml stop app
gunzip -c backups/horaires-20260920-030000.sql.gz \
| docker compose exec -T db psql -U horaires -d horaires
docker compose -f docker-compose.yml -f docker-compose.prod.yml start app
```
Après une restauration, les écrans appairés depuis le dump ne le sont plus du
point de vue de la base : ils recevront un 401 et devront être réappairés par le
portail captif. Rien d'autre ne se perd.
## Retour arrière
L'image porte la version du dépôt, donc revenir en arrière veut dire revenir au
commit :
```bash
git log --oneline -10
git checkout <commit-précédent>
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
```
**Si la mise à jour comportait une migration**, revenir au code ne suffit pas :
le schéma est déjà migré. Prisma ne défait pas une migration. Restaurez alors le
dump pris avant la mise à jour, puis reprenez le code. C'est la raison d'être de
la sauvegarde préalable.
## Ce qu'il faut surveiller
| Signe | Où | Que faire |
|---|---|---|
| L'écran n'a pas donné signe de vie | alerte du tableau de bord | batterie, puis Wi-Fi de la boutique |
| Synchro des fériés en échec | alerte, page Fériés | le cache local tient ; relancer à la main |
| Messages sans traduction | alerte, page Messages | `npm run translate:test` pour savoir pourquoi |
| Conteneur *unhealthy* | `docker compose ps` | `docker compose logs app` |
## Le panneau tombe sur le certificat TLS
Certains firmwares ESP32 échouent sur une chaîne de certificats que tous les
navigateurs acceptent. Si l'écran n'arrive à joindre le serveur qu'en clair,
`DEVICE_ALLOW_HTTP=true` existe — mais c'est le dernier recours, pas le premier.
Dans l'ordre :
1. Vérifiez que l'écran atteint bien le domaine : `docker compose logs app | grep /api/setup`.
2. Vérifiez la chaîne servie : `openssl s_client -connect horaires.ita-ito.com:443 -servername horaires.ita-ito.com | head -20`. Une chaîne incomplète est le cas le plus fréquent et se corrige côté Traefik, pas côté écran.
3. En dernier recours seulement, exposez un hôte virtuel en clair réservé aux
routes `/api/setup`, `/api/display`, `/api/log` et `/api/device/image/*`.
Le jeton d'appareil circulerait alors en clair : il est distinct de tout
autre secret et révocable depuis *Paramètres → Appareils*, ce qui rend
l'arbitrage tenable — mais l'administration, elle, reste en TLS.