INVARIANTS — Contrat d’architecture (Template Apps)
Introduction
Ce document est le contrat d’architecture à respecter pour toutes les applications issues du template (MDP, Calendrier, futures apps).
Règles générales :
- Toute proposition doit être compatible avec le code existant (pas de régression).
- Les correctifs doivent être minimaux, incrémentaux et réversibles.
- Les secrets ne doivent jamais apparaître dans des fichiers
.envversionnés. - Tout snippet fourni doit être autosuffisant et prêt à copier (avec le chemin si partiel).
- En production, Traefik est le frontal unique (ne jamais supposer Apache/Nginx directement sur 80/443).
1) Nommage et arborescence
1.1 Variables canoniques
- APP_DEPOT : nom du dépôt GitHub (= répertoire en DEV) →
~/projets/${APP_DEPOT}. - APP_SLUG : préfixe stable pour images/containers/volumes/réseau et répertoire PROD →
/opt/apps/${APP_SLUG}. - APP_ENV ∈
{dev, prod}. - APP_NAME : nom humain (entêtes/logs/outils).
1.2 Compose — services et ressources Docker
- Noms de services fixes (cross-apps) :
db,backend,vite,frontend. - Ressources Docker :
${APP_SLUG}_<service>_${APP_ENV}. - Réseau par défaut :
${APP_SLUG}_appnet.
2) Ports DEV dérivés de APP_NO
À partir de APP_NO = N (ex. N=1) :
DEV_DB_PORT = 5432 + N(ex. 5433)DEV_API_PORT = 8001 + N(ex. 8002)DEV_VITE_PORT = 5173 + N(ex. 5174)
Ces ports sont la source de vérité : utilisés dans Compose et par les scripts.
2bis) Dérivations depuis APP_SLUG et APP_NO
2bis.1 Variables canoniques
APP_SLUG: identifiant court de l’app (ex.mdp,cal) ; préfixe pour images/containers/volumes/réseaux et dossier PROD/opt/apps/${APP_SLUG}.APP_NO = N: index numérique pour dériver les ports hôte en DEV et éviter les collisions inter-apps.APP_ENV ∈ {dev, prod}: sélection d’environnement.
2bis.2 Dérivées à partir de APP_SLUG
PostgreSQL
POSTGRES_USER = ${APP_SLUG}_pg_userPOSTGRES_DB = ${APP_SLUG}_pg_dbPOSTGRES_PASSWORD: uniquement dans.env.$(APP_ENV).local(non commit)- Host interne (Docker DNS) :
db - Port interne : 5432
Nommage Docker
- Containers :
${APP_SLUG}_<service>_${APP_ENV}(services =db,backend,vite,frontend) - Réseau par défaut :
${APP_SLUG}_appnet - Volume DB recommandé :
${APP_SLUG}_db_data
Répertoires
- DEV :
~/projets/${APP_DEPOT}(où${APP_DEPOT}= nom du repo Git) - PROD :
/opt/apps/${APP_SLUG}
2bis.3 Dérivées à partir de APP_NO
Ports hôte (DEV)
DEV_DB_PORT = 5432 + NDEV_API_PORT = 8001 + NDEV_VITE_PORT = 5173 + N
Accès extérieur (DEV, optionnel) : pour se connecter à Postgres depuis l’hôte (ou pgAdmin), publier
${DEV_DB_PORT}:5432(avec${DEV_DB_PORT}=5432+N). Les autres conteneurs utilisent toujoursdb:5432.
2bis.4 Rappel des ports internes
- DB
5432, backend8000, vite5173→ fixes dans les conteneurs ; seul le port hôte varie viaAPP_NO.
3) Environnements et secrets
3.1 Fichiers conservés (canon)
.env.dev,.env.prod: variables non sensibles (versionnées)..env.local.example: modèle de secrets (non sensible) pour guider la création de.env.localnon versionné.
Interdit : secrets dans .env.dev / .env.prod.
Les secrets réels doivent être conservés uniquement dans *.local (non commit) :
POSTGRES_PASSWORDDJANGO_SECRET_KEYADMIN_USERNAMEADMIN_PASSWORDADMIN_EMAIL- etc.
Un symlink .env pointe toujours vers .env.$(APP_ENV).
3bis) Postgres — Host, rôle, DB et connexion standard
3bis.1 Invariants de connexion
- Host (réseau Compose) :
db - Port :
5432 - Utilisateur :
${POSTGRES_USER}(=${APP_SLUG}_pg_user) - Base :
${POSTGRES_DB}(=${APP_SLUG}_pg_db) - Accès hors conteneur (DEV, optionnel) :
localhost:${DEV_DB_PORT}(mapping${DEV_DB_PORT}:5432).
3bis.2 Exemples .env.dev / .env.prod (versionnés, sans secrets)
# .env.dev (exemple)
APP_ENV=dev
APP_SLUG=mdp
APP_DEPOT=gestionnaire_mdp_zero_knowledge
APP_NO=1
# Dérivées DB (sans mot de passe ici)
POSTGRES_USER=${APP_SLUG}_pg_user
POSTGRES_DB=${APP_SLUG}_pg_db
# Ports hôte (DEV)
DEV_DB_PORT=$((5432 + APP_NO))
DEV_API_PORT=$((8001 + APP_NO))
DEV_VITE_PORT=$((5173 + APP_NO))
# Frontend: base API toujours relative
VITE_API_BASE=/api3bis.2bis Fichiers non versionnés (secrets)
# .env.local (exemple — non commit)
POSTGRES_PASSWORD=change_me
DJANGO_SECRET_KEY=django-unsafe-dev-…
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.test
ADMIN_PASSWORD=adminpass3bis.3 Compose (DEV) — fragments normatifs
Service DB (host interne = db)
services:
db:
image: postgres:16-alpine
container_name: ${APP_SLUG}_db_${APP_ENV}
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "${DEV_DB_PORT}:5432" # 5432 interne → 5432+N côté hôte (si besoin)
volumes:
- ${APP_SLUG}_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1"]
interval: 5s
timeout: 3s
retries: 10Service backend (Django)
services:
backend:
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
ports:
- "${DEV_API_PORT}:8000" # 8000 interne → 8001+N côté hôteService vite (dev server + proxy API)
services:
vite:
environment:
VITE_API_BASE: "/api" # chemin relatif (constant)
ports:
- "${DEV_VITE_PORT}:5173" # 5173 interne → 5173+N côté hôteRappel : en PROD, on ne publie jamais 80/443 depuis les apps — Traefik est le frontal unique. Un bind
127.0.0.1:${PROD_API_PORT}→8000peut exister pour debug/health local uniquement, jamais exposé publiquement.
3bis.4 Commandes psql canoniques
Depuis le conteneur backend (recommandé) :
docker compose --env-file .env.dev -f docker-compose.dev.yml exec backend \
psql -h db -p 5432 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\\dt'Depuis l’hôte (si port DEV DB publié) :
psql -h localhost -p "$DEV_DB_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c 'select 1;'3bis.5 Accès externe et tunnels
- DEV : publier
${DEV_DB_PORT}:5432uniquement si nécessaire. Exemple :psql -h localhost -p "$DEV_DB_PORT" .... - PROD (recommandé) : ne pas publier la DB. Utiliser un tunnel SSH ponctuel :
ssh -N -L 15433:127.0.0.1:5432 user@mon-serveur
# côté local :
psql -h localhost -p 15433 -U "$POSTGRES_USER" -d "$POSTGRES_DB"- Si publication PROD imposée (debug court) : binder sur
127.0.0.1:PORTseulement, restreindre via UFW, et retirer l’exposition ensuite.
4) Conteneurisation (Compose)
4.1 Services
db: Postgres16-alpinebackend: Django ; image${APP_SLUG}-backend:dev|prodvite: Node20-alpine; dev serverfrontend: optionnel en dev ; en prod, build statique servi derrière Traefik
4.2 Dépendances et healthchecks
backenddépend dedb(healthy)vitepeut dépendre debackenden dev
4.3 Réseau et noms
Tous les containers/volumes/réseaux suivent ${APP_SLUG}_<nom>_${APP_ENV}.
5) Backend Django (API)
- Commande dev :
python manage.py runserver 0.0.0.0:8000. - Base API : préfixe
/api/(dansurls.pydu projet).
5.1 Auth
- SimpleJWT activé (
rest_framework_simplejwt,JWTAuthentication).
5.2 Endpoints JWT
/api/auth/jwt/create//api/auth/jwt/refresh//api/auth/jwt/verify/
Whoami JWT :
- canon :
/api/whoami/ - alias compat :
/api/auth/whoami/
5.3 CORS/CSRF en dev
CORS_ALLOWED_ORIGINS = http://localhost:${DEV_VITE_PORT}CSRF_TRUSTED_ORIGINS = http://localhost, http://127.0.0.1, http://localhost:${DEV_VITE_PORT}[, http://localhost:${DEV_API_PORT}]
5.4 ALLOWED_HOSTS (dev minimal)
localhost,127.0.0.1,0.0.0.0,<noms des containers>.
5.5 Compat sessions (optionnel)
Les endpoints csrf/, login/, logout/ peuvent subsister mais les nouvelles features doivent viser JWT.
6) Frontend (Vite + React + Axios)
6.1 Vite (dev) — frontend/vite.config.js
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173, // mappé -> 5173+N côté hôte
proxy: { '/api': { target: 'http://backend:8000', changeOrigin: false } },
},
})6.2 Variables front
VITE_API_BASE = /api(chemin relatif en dev & prod) — injectée via Compose dansvite.
6.3 Axios (frontend/src/api.js) — points clés
BASE = normalizeBase(import.meta.env.VITE_API_BASE)api = axios.create({ baseURL: BASE })- Login :
api.post('auth/jwt/create/', { username, password }) - Intercepteur 401 → purge
Authorization+ redirection/login. - Tokens : source unique
localStorage.setItem('mdp.jwt', JSON.stringify({ access, refresh }))(+ compattokenen lecture si présent).
7) Flux dev attendu
viteécoute5173(conteneur) →5173+N(hôte).- Le navigateur appelle
/api/...→ Vite proxy vershttp://backend:8000. - Django répond ; CORS/CSRF/ALLOWED_HOSTS alignés sur l’origine dev.
8) Compose (dev) — points d’attention
env_file: .env.devet.env.localpourdb,backend,vite.backend.ports: "${DEV_API_PORT}:8000"vite.ports: "${DEV_VITE_PORT}:5173"VITE_API_BASE: "/api"injecté dansvite.- Jamais de secrets dans
.env.dev(ni en clair dans la doc/exemples).
9) Vérification automatique des invariants
Script : scripts/verifier-invariants.sh (à lancer avant toute PR).
9.1 Vérifications
- Vite proxy (
/api, targetbackend:8000,changeOrigin:false). frontend/src/api.js(import axios,VITE_API_BASE,axios.create, endpoint JWT).- Django (SimpleJWT, URLs JWT/
whoami,runserver 0.0.0.0:8000). - Compose (ports,
VITE_API_BASE:"/api").
9.2 Smoke tests
- création JWT via
http://localhost:${DEV_VITE_PORT}/api/auth/jwt/create/ verify+ appel endpoint protégé (/api/whoami/).
Toute PR doit passer ce script en local.
9bis) Vérification automatique — ajouts
Le script doit aussi valider :
- cohérence
POSTGRES_USER=${APP_SLUG}_pg_useretPOSTGRES_DB=${APP_SLUG}_pg_db; - host DB du backend =
db:5432(pas d’IP en dur) ; - ports hôte DEV respectant
5432+N,8001+N,5173+N; VITE_API_BASE=/api(relatif) en dev et en prod.
10) Production (survol)
- Code déployé sous
/opt/apps/${APP_SLUG};.envsymlink →.env.prod. VITE_API_BASEreste/api.- Traefik publie
/api→ backend (gunicorn/uwsgi), et les assets statiques du front. - Jamais de bind direct 80/443 dans les containers applicatifs.
ALLOWED_HOSTScontientAPP_HOST(+ alias).
11) Règles Do / Don’t
Do
- Garder
/apirelatif côté front (pas d’URL absolue). - Conserver les noms de services Compose (
db,backend,vite,frontend). - Centraliser les secrets dans
*.local(non commit). - Utiliser
mdp.jwtcomme source unique pour les tokens. - Respecter les chemins de travail :
~/projets/${APP_DEPOT}(dev) ;/opt/apps/${APP_SLUG}(prod). - Préférer JWT pour les nouveaux flux d’auth.
- Toujours référencer la DB via
db:5432dans les apps. - Toujours dériver
POSTGRES_USER/POSTGRES_DBdepuisAPP_SLUG. - Toujours dériver les ports DEV depuis
APP_NO; publier${DEV_DB_PORT}:5432uniquement si nécessaire.
Don’t
- Pas de secrets dans
.env.dev/.env.prod. - Pas de
changeOrigin:trueen dev pour/api. - Pas d’URL API codée en dur (toujours
VITE_API_BASE). - Pas d’écoute 80/443 par l’app en prod (Traefik front-only).
- Pas d’hôte DB en dur différent de
db. - Pas de
POSTGRES_USER/POSTGRES_DBhors schéma${APP_SLUG}_pg_*. - Pas d’URL API absolue côté front ; pas de 80/443 exposés par les apps en PROD.
12) Checklist de revue (avant merge)
-
scripts/verifier-invariants.shOK -
frontend/vite.config.jsconforme -
frontend/src/api.jsconforme (BASE, axios, login endpoint, interceptor) - Backend dev :
runserver 0.0.0.0:8000 -
CORS_ALLOWED_ORIGINSincluthttp://localhost:${DEV_VITE_PORT} -
CSRF_TRUSTED_ORIGINSlistées (localhost/127.0.0.1/ports dev) -
ALLOWED_HOSTSminimal - Secrets présents uniquement dans
*.local(non commit) - Chemins conformes (dev :
~/projets/${APP_DEPOT}, prod :/opt/apps/${APP_SLUG})
13) Emplacement du document
- Chemin recommandé :
docs/INVARIANTS.md. - Lien depuis
README.md.
14) Extension cross-apps
Ce document sert de template pour d’autres applications : mêmes noms de services, mêmes conventions d’API/auth, ports dérivés via APP_NO, mêmes scripts de vérification. Seules changent les valeurs d’APP_* et le métier.
Annexes
A) Makefile — cibles standard
- But par défaut :
help(liste des cibles).
Garde-fous :
envlink:.env -> .env.$(APP_ENV)ensure-env: symlink valide, pas de dev sur hôte prodensure-edge: réseauedge
Stack : up, down, restart, ps, logs.
Django : makemigrations, migrate, createsuperuser, psql.
Prod : prod-deploy (build+up+migrate+collectstatic), prod-health, prod-logs.
Utilitaires : dps (tri par NAMES, filtré app courante), dps-all.
Nota :
LOAD_LOCALcharge automatiquement.env.$(APP_ENV).local(pas de;;).
B) URLs Django — fragment de référence
# backend/api/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import CategoryViewSet, PasswordViewSet, healthz
from .views_auth import csrf, login_view, logout_view # compat sessions
from api.views_jwt_whoami import jwt_whoami
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
app_name = "api"
router = DefaultRouter()
router.register(r"categories", CategoryViewSet, basename="category")
router.register(r"passwords", PasswordViewSet, basename="password")
urlpatterns = [
path("", include(router.urls)),
path("healthz/", healthz, name="api-healthz"),
path("csrf/", csrf, name="api-csrf"), # compat
path("login/", login_view, name="api-login"), # compat
path("logout/", logout_view, name="api-logout"), # compat
path("whoami/", jwt_whoami, name="api-whoami"),
path("auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"),
path("auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"),
path("auth/jwt/verify/", TokenVerifyView.as_view(), name="jwt-verify"),
path("auth/whoami/", jwt_whoami, name="jwt-whoami"), # alias
]C) Vite (dev) — fragment de référence
// frontend/vite.config.js
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173, // mappé -> 5173+N côté hôte
proxy: { '/api': { target: 'http://backend:8000', changeOrigin: false } },
},
})Conclusion
Ces invariants constituent la base commune de toutes les apps du template. Toute évolution doit préserver :
- la dérivation stable des noms et des ports,
- l’usage strict de
db:5432côté réseau Compose, - l’absence de secrets dans les fichiers versionnés,
- et l’exposition uniquement via Traefik en production.