Aller au contenu

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+), kubectl configuré 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é...)
  • openssl en local pour générer les secrets

Architecture cible

Architecture cible Kubernetes : Ingress vers Service vers Deployment, qui lit un Secret et une ConfigMap, écrit dans le StatefulSet MariaDB et monte un volume partagé pour les uploads

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

kubectl create namespace urbafix

É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"
kubectl apply -f configmap.yaml

É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
kubectl apply -f mariadb.yaml

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
kubectl apply -f uploads-pvc.yaml

É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.

kubectl apply -f web.yaml

É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 }
kubectl apply -f ingress.yaml

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.