Déploiement Kubernetes (auto-hébergement)¶
⬇️ Télécharger cette page en Markdown
Cas d'usage
Cette procédure s'adresse à une collectivité qui souhaite héberger UrbaFix sur sa propre infrastructure Kubernetes, plutôt que d'utiliser une instance SaaS. Le déploiement de référence (urbafix.fr) tourne sur un unique hôte Docker Compose derrière un reverse proxy — voir Déploiement - Procédures Production pour ce cas. Cette page couvre le portage vers k8s, avec les adaptations que ça implique.
Périmètre et limites connues¶
UrbaFix n'a pas été conçu nativement "cloud-native" — le packager pour k8s est possible sans réécriture, mais avec des contraintes à connaître avant de se lancer :
| Composant | Comportement actuel | Implication k8s |
|---|---|---|
Rate limiting (SecurityHelpers::checkRateLimit) |
Compteur APCu, en mémoire locale du process | Avec plusieurs replicas, chaque pod a son propre compteur : la limite réelle est multipliée par le nombre de replicas. OK avec 1 seul replica (section principale) ; migration Redis nécessaire au-delà (voir Variante — plusieurs replicas) |
Photos/vidéos (uploads/incidents/, uploads/videos/) |
Écriture sur disque local du conteneur | Nécessite un volume partagé entre tous les pods (PVC en mode ReadWriteMany), sinon chaque pod voit un sous-ensemble différent des fichiers |
Clé de chiffrement photos (EncryptionManager) |
Lue depuis le fichier /run/secrets/app_encryption_key (convention Docker Secret) |
Se transpose directement en volume k8s Secret monté à ce chemin exact — aucun changement de code |
Whitelist CORS (SecurityHelpers::getAllowedOrigins()) |
Codée en dur dans backend/public/api/SecurityHelpers.php (domaines urbafix.fr) |
À modifier dans le code source et reconstruire l'image avec le(s) domaine(s) réel(s) de la mairie avant déploiement — sans ça, le navigateur bloque les appels API du front vers l'API |
| Sessions PHP backoffice | Fichiers locaux (session.save_handler par défaut) |
OK avec 1 replica ; nécessiterait un handler partagé (Redis) au-delà, même limitation que le rate limiting |
| Schéma de base de données | Pas de fichier SQL unique à jour — database/schema_current.sql + une quinzaine de fichiers dans database/migrations/ appliqués au fil du développement, non garantis idempotents |
Voir Étape 6 — prévoir un test sur une base de staging avant toute mise en prod |
Recommandation : démarrer avec 1 seul replica web (section principale de cette page). C'est suffisant pour la charge d'une mairie ou petite collectivité, et évite d'avoir à toucher au code applicatif. La variante multi-replicas est documentée à part, avec le changement de code qu'elle implique.
Prérequis cluster¶
- Un cluster Kubernetes opérationnel (1.27+),
kubectlconfiguré avec un contexte pointant dessus - Un ingress controller (ex.
ingress-nginx) déjà installé - cert-manager (ou certificats TLS fournis autrement par la DSI de la mairie) pour le HTTPS — obligatoire, voir Conformité ANSSI — TLS 1.3
- Un StorageClass supportant
ReadWriteMany(NFS, Longhorn, CephFS, etc.) pour le volume des uploads - Un registry d'images accessible au cluster (Harbor interne, GitLab/GitHub Container Registry, Docker Hub privé...)
opensslen local pour générer les secrets
Architecture cible¶
Un seul type de conteneur applicatif (nginx + PHP-FPM dans la même image, comme en local — voir backend/Dockerfile), pas de split en deux conteneurs.
Étape 1 — Adapter et construire l'image¶
1.1 Domaine(s) CORS¶
Avant tout, éditer backend/public/api/SecurityHelpers.php (getAllowedOrigins()) pour remplacer https://urbafix.fr / https://www.urbafix.fr par le(s) domaine(s) réel(s) de la mairie. C'est codé en dur, pas piloté par variable d'environnement — un oubli ici bloque silencieusement tous les appels API du front (le navigateur refuse la réponse, sans erreur serveur visible côté PHP).
1.2 Build et push¶
docker build -t registry.mairie.fr/urbafix/web:2.9.2 -f backend/Dockerfile backend/
docker push registry.mairie.fr/urbafix/web:2.9.2
Étape 2 — Namespace¶
Étape 3 — Secrets¶
Génération des valeurs (mêmes exigences que le déploiement Docker Compose, voir deployment.md et Conformité ANSSI §4) :
DB_PASSWORD=$(openssl rand -base64 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -base64 32)
APP_SECRET_KEY=$(openssl rand -hex 64)
JWT_SECRET=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 64)
kubectl create secret generic urbafix-secrets \
--namespace urbafix \
--from-literal=DB_PASSWORD="$DB_PASSWORD" \
--from-literal=MYSQL_ROOT_PASSWORD="$MYSQL_ROOT_PASSWORD" \
--from-literal=APP_SECRET_KEY="$APP_SECRET_KEY" \
--from-literal=JWT_SECRET="$JWT_SECRET" \
--from-file=app_encryption_key=<(echo -n "$ENCRYPTION_KEY")
JWT_SECRET obligatoire
Le serveur refuse de démarrer sans JWT_SECRET (aucun fallback faible accepté) — voir Conformité ANSSI §4.
app_encryption_key sera monté en fichier (pas en variable d'env) à l'étape 7, pour respecter le chemin /run/secrets/app_encryption_key attendu par EncryptionManager.
Étape 4 — ConfigMap¶
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: urbafix-config
namespace: urbafix
data:
APP_ENV: "production"
APP_DEBUG: "false"
APP_URL: "https://urbafix.mairie.fr"
DB_HOST: "mariadb"
DB_NAME: "urbafix"
DB_USER: "urbafix_user"
TIMEZONE: "Europe/Paris"
MAX_UPLOAD_SIZE: "64M"
UPLOAD_DIR: "/var/www/html/uploads"
Étape 5 — Base de données¶
Option A — MariaDB in-cluster (StatefulSet)¶
Adaptée à une mairie sans base de données managée existante ; les sauvegardes sont à la charge du cluster (CronJob, voir Sauvegardes).
# mariadb.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb-data
namespace: urbafix
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mariadb
namespace: urbafix
spec:
serviceName: mariadb
replicas: 1
selector:
matchLabels: { app: mariadb }
template:
metadata:
labels: { app: mariadb }
spec:
containers:
- name: mariadb
image: mariadb:10.11
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom: { secretKeyRef: { name: urbafix-secrets, key: MYSQL_ROOT_PASSWORD } }
- name: MYSQL_DATABASE
valueFrom: { configMapKeyRef: { name: urbafix-config, key: DB_NAME } }
- name: MYSQL_USER
valueFrom: { configMapKeyRef: { name: urbafix-config, key: DB_USER } }
- name: MYSQL_PASSWORD
valueFrom: { secretKeyRef: { name: urbafix-secrets, key: DB_PASSWORD } }
ports:
- containerPort: 3306
volumeMounts:
- name: data
mountPath: /var/lib/mysql
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
volumes:
- name: data
persistentVolumeClaim: { claimName: mariadb-data }
---
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: urbafix
spec:
selector: { app: mariadb }
ports:
- port: 3306
Option B — Base de données managée externe¶
Si la mairie dispose déjà d'un service de BDD managé (rare pour une petite collectivité, plus courant pour une métropole/EPCI avec DSI mutualisée) : ne pas déployer le StatefulSet ci-dessus, et renseigner DB_HOST dans la ConfigMap avec l'hôte fourni par le service managé. Le reste de la procédure est identique — l'application ne fait aucune hypothèse sur la nature de l'hôte MySQL/MariaDB.
Étape 6 — Schéma et migrations¶
Pas de schéma unique à jour
database/schema_current.sql date du 2026-02-02 ; une quinzaine de fichiers dans database/migrations/ ont été appliqués depuis (statut ATTENTE, suivi de position agents, affectation multiple, WebAuthn, carnet de contacts...). Il n'existe pas de fichier consolidé garanti à jour et l'idempotence de chaque migration n'est pas garantie individuellement (au moins un cas connu documenté dans backend/CLAUDE.md — bloc 4 de incident_attente_status.sql). Tester la séquence complète sur une base de staging avant toute mise en production.
# Récupérer le pod mariadb
POD=$(kubectl get pod -n urbafix -l app=mariadb -o jsonpath='{.items[0].metadata.name}')
# Schéma de base
kubectl exec -n urbafix -i "$POD" -- mysql -uroot -p"$MYSQL_ROOT_PASSWORD" urbafix < backend/database/schema_current.sql
# Migrations, dans l'ordre alphabétique des fichiers (à vérifier un par un en staging)
for f in backend/database/migrations/*.sql; do
echo "Applying $f..."
kubectl exec -n urbafix -i "$POD" -- mysql -uroot -p"$MYSQL_ROOT_PASSWORD" urbafix < "$f"
done
Étape 7 — Volume des uploads¶
# uploads-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: urbafix-uploads
namespace: urbafix
spec:
accessModes: ["ReadWriteMany"]
storageClassName: <votre-storageclass-rwx> # ex. nfs-client, longhorn
resources:
requests:
storage: 50Gi
Étape 8 — Deployment et Service web¶
# web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: urbafix-web
namespace: urbafix
spec:
replicas: 1 # voir la variante multi-replicas plus bas avant d'augmenter
selector:
matchLabels: { app: urbafix-web }
template:
metadata:
labels: { app: urbafix-web }
spec:
containers:
- name: web
image: registry.mairie.fr/urbafix/web:2.9.2
ports:
- containerPort: 80
envFrom:
- configMapRef: { name: urbafix-config }
env:
- name: DB_PASSWORD
valueFrom: { secretKeyRef: { name: urbafix-secrets, key: DB_PASSWORD } }
- name: APP_SECRET_KEY
valueFrom: { secretKeyRef: { name: urbafix-secrets, key: APP_SECRET_KEY } }
- name: JWT_SECRET
valueFrom: { secretKeyRef: { name: urbafix-secrets, key: JWT_SECRET } }
volumeMounts:
- name: uploads
mountPath: /var/www/html/uploads
- name: encryption-key
mountPath: /run/secrets
readOnly: true
readinessProbe:
httpGet: { path: /health.php, port: 80 }
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet: { path: /health.php, port: 80 }
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "1", memory: "512Mi" }
volumes:
- name: uploads
persistentVolumeClaim: { claimName: urbafix-uploads }
- name: encryption-key
secret:
secretName: urbafix-secrets
items:
- key: app_encryption_key
path: app_encryption_key
---
apiVersion: v1
kind: Service
metadata:
name: urbafix-web
namespace: urbafix
spec:
selector: { app: urbafix-web }
ports:
- port: 80
targetPort: 80
health.php
health.php est réservé en nginx aux appels internes (allow 127.0.0.1; allow ::1; deny all; — voir backend/nginx.conf). Les probes Kubernetes appellent le pod depuis le même conteneur/localhost via le kubelet, donc c'est compatible tel quel ; ne pas exposer cette route via l'Ingress.
Étape 9 — Ingress et TLS¶
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: urbafix
namespace: urbafix
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "64m" # aligné sur MAX_UPLOAD_SIZE
spec:
ingressClassName: nginx
tls:
- hosts: ["urbafix.mairie.fr"]
secretName: urbafix-tls
rules:
- host: urbafix.mairie.fr
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: urbafix-web
port: { number: 80 }
Vérifications post-déploiement¶
kubectl get pods -n urbafix
kubectl logs -n urbafix -l app=urbafix-web --tail=50
# Headers de sécurité (voir Conformité ANSSI §2)
curl -sI https://urbafix.mairie.fr | grep -E "X-Frame|Content-Security|Strict-Transport"
# CORS : doit renvoyer Access-Control-Allow-Origin uniquement pour le domaine attendu
curl -H "Origin: https://urbafix.mairie.fr" -I https://urbafix.mairie.fr/api/get_types.php?code_postal=06500
Créer ensuite la première mairie et le compte admin via le flux d'auto-inscription (/register.html), voir Mairie Self-Registration Flow et backend/CLAUDE.md.
Variante — passer à plusieurs replicas¶
Ne s'applique qu'après avoir traité les deux limitations mono-process listées en début de page. Changement de code minimal, à faire une fois pour toutes :
Rate limiting : APCu → Redis¶
# redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: urbafix
spec:
replicas: 1
selector: { matchLabels: { app: redis } }
template:
metadata: { labels: { app: redis } }
spec:
containers:
- name: redis
image: redis:7-alpine
args: ["--requirepass", "$(REDIS_PASSWORD)"]
env:
- name: REDIS_PASSWORD
valueFrom: { secretKeyRef: { name: urbafix-secrets, key: REDIS_PASSWORD } }
ports: [{ containerPort: 6379 }]
---
apiVersion: v1
kind: Service
metadata: { name: redis, namespace: urbafix }
spec:
selector: { app: redis }
ports: [{ port: 6379 }]
Dans backend/public/api/SecurityHelpers.php, remplacer le backend apcu_fetch/apcu_store/apcu_inc de checkRateLimit() par des appels Redis (extension phpredis, à ajouter au Dockerfile) partageant un compteur entre tous les pods — même logique de fenêtre glissante, juste un magasin partagé au lieu d'un magasin local. Reconstruire et republier l'image après ce changement.
Sessions PHP backoffice¶
Même raisonnement : passer session.save_handler sur Redis (session.save_handler = redis, session.save_path = "tcp://redis:6379") dans security.ini, pour que la session backoffice survive au routage vers un pod différent entre deux requêtes.
Une fois ces deux points traités, replicas: 2 (ou plus) dans web.yaml devient sûr, et un HorizontalPodAutoscaler peut être ajouté si la charge le justifie — improbable pour une seule collectivité, plus pertinent pour un hébergement mutualisé multi-mairies.
Sauvegardes¶
Si MariaDB est in-cluster (Option A), planifier un CronJob de dump régulier vers un stockage externe au cluster (S3-compatible, NFS distinct) — un PVC seul sur le même cluster ne protège pas d'une perte du cluster entier :
# backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: mariadb-backup
namespace: urbafix
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: mariadb:10.11
command:
- sh
- -c
- "mysqldump -h mariadb -uroot -p$MYSQL_ROOT_PASSWORD urbafix | gzip > /backup/urbafix_$(date +%Y%m%d).sql.gz"
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom: { secretKeyRef: { name: urbafix-secrets, key: MYSQL_ROOT_PASSWORD } }
volumeMounts:
- name: backup
mountPath: /backup
restartPolicy: OnFailure
volumes:
- name: backup
persistentVolumeClaim: { claimName: urbafix-backup }
Penser aussi au volume urbafix-uploads (photos/vidéos originales) — non couvert par un dump SQL.
Mises à jour¶
docker build -t registry.mairie.fr/urbafix/web:2.9.3 -f backend/Dockerfile backend/
docker push registry.mairie.fr/urbafix/web:2.9.3
kubectl set image deployment/urbafix-web web=registry.mairie.fr/urbafix/web:2.9.3 -n urbafix
kubectl rollout status deployment/urbafix-web -n urbafix
Appliquer les migrations SQL manquantes (étape 6) avant le rollout si la nouvelle version en attend le schéma.