Aller au contenu
Me contacter

Ouvert à un CDI · Métropole lilloise

Tous les articles
9 min de lecture

Déployer Payload CMS en production : Docker, migrations PostgreSQL et sauvegardes testées

Ce qu’il faut prévoir pour héberger soi-même une application Payload et Next.js : une image construite hors du serveur, des migrations appliquées au démarrage, un retour arrière qui tient compte du schéma, et des sauvegardes dont la restauration est vérifiée. Les exemples et les pièges viennent de mon socle de sites vitrines.

  • Payload CMS
  • Next.js
  • Docker
  • PostgreSQL
  • restic
  • Déploiement

Payload 3 s’installe dans l’application Next.js, si bien qu’un site et son CMS ne forment qu’un seul projet à déployer. J’ai présenté ce fonctionnement dans l’article sur mon socle de sites vitrines. Celui-ci traite de la suite : faire tourner ce projet sur un VPS, un serveur virtuel loué, sans service managé pour s’occuper de la base ou des fichiers. La procédure a été conçue pour des mises en production de sites clients, et je l’ai éprouvée sur le staging d’Atelier 33, le site de démonstration du socle. Les sections suivent le trajet d’une version, de la construction de l’image jusqu’au jour où il faudrait la restaurer.

Comment construire l’image d’une application Payload ?

La première décision concerne l’endroit du build. Dans ce projet, next build consomme environ 4 Go de RAM, assez pour être tué par le noyau sur une machine de 7,7 Go déjà chargée. L’image est donc construite par la CI, à chaque push sur la branche principale, puis publiée sur GHCR, le registre d’images de GitHub, avec un tag par commit. Le VPS se contente de la télécharger, et n’a pas à être dimensionné pour le build.

Un même Dockerfile produit deux images. L’image runner ne contient que la sortie standalone de Next.js, le serveur minimal et les seuls fichiers qu’il utilise. Elle ne peut donc lancer ni un seed (le script qui insère les données initiales) ni une migration manuelle, faute de sources et de devDependencies. Ces tâches ponctuelles passent par une image migrator, déclarée dans un profil Docker Compose pour qu’un simple up ne la démarre jamais.

# Simplified. One-off tasks: full sources and devDependencies
FROM builder AS migrator
ENV NODE_ENV=production
USER nextjs
CMD ["pnpm", "run", "migrate"]

# The website: standalone output only
FROM base AS runner
ENV NODE_ENV=production
RUN mkdir media && chown nextjs:nodejs media
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
CMD HOSTNAME="0.0.0.0" node server.js

Deux lignes de cet extrait répondent à des pièges. La sortie standalone ne copie ni public ni .next/static, comme le précise la documentation de Next.js, et il faut les ajouter pour que le favicon et les fichiers statiques existent. Le dossier media, lui, doit appartenir à l’utilisateur de l’application, faute de quoi tout upload depuis l’administration échoue sur un refus de permission. Docker initialise un volume nommé vide avec le contenu et les droits du dossier de l’image, ce qui règle le problème dès le premier démarrage.

Avant publication, la CI démarre le vrai fichier Compose de production sur ces images, puis contrôle les pages publiques, le favicon, le seed, la connexion à l’administration et les droits des médias. Ce smoke test, un test rapide qui vérifie que l’essentiel fonctionne, conditionne la publication, et aucun tag ne sort s’il échoue. Sa place dans l’ensemble des tests est décrite dans l’article sur la stratégie de tests.

Pourquoi le mode push doit-il rester hors de la production ?

Avec PostgreSQL, Payload active par défaut un mode push en développement : le schéma de la base suit directement la configuration, sans fichier de migration. C’est confortable en local, et la documentation le recommande pour cet usage, tout en demandant de ne pas le mélanger aux migrations sur une même base. Pour la production, la commande migrate:create compare la configuration aux migrations existantes et génère le SQL correspondant, que le dépôt versionne. Le socle en compte 9.

Reste à les appliquer. L’option prodMigrations demande à Payload de les exécuter pendant son initialisation, en production uniquement. La documentation signale qu’elle peut ralentir les démarrages à froid en serverless, ce qui ne gêne pas un conteneur de longue durée. Une condition s’y ajoute ici :

db: postgresAdapter({
  pool: { connectionString: process.env.DATABASE_URL },
  // next build also initializes Payload with NODE_ENV=production:
  // without this check, it would migrate whatever database it can reach
  prodMigrations:
    process.env.NEXT_PHASE === 'phase-production-build' ? undefined : migrations,
}),

Le piège le plus sérieux se trouve dans les scripts. NODE_ENV vaut production pour le serveur, mais une commande lancée à part ne l’hérite pas forcément. Un seed exécuté contre la base de production avec une autre valeur fait passer Payload en mode push, qui modifie le schéma hors migrations et ajoute dans payload_migrations une ligne dont le batch vaut -1. Sur la version 3.90 que j’utilise, l’application reste bloquée à sa prochaine initialisation sur une question de confirmation interactive, à laquelle personne ne peut répondre dans un conteneur. L’API et l’administration ne répondent plus.

La documentation de Payload ne décrit pas ce cas, que j’ai relevé dans son code et dans le comportement du socle. La parade tient en une ligne, ENV NODE_ENV=production dans l’image migrator, protégée par un test unitaire pour qu’elle ne disparaisse pas lors d’une modification du Dockerfile.

Comment déployer et revenir en arrière ?

Le pipeline de CI/CD teste, construit et publie une image prête à déployer à chaque push sur la branche principale. Le passage en production reste une décision explicite, pour éviter de stocker dans GitHub une clé SSH root du VPS et d’envoyer chaque push en ligne sans contrôle. Il se déclenche avec une commande, scripts/deploy.sh, qui refuse de partir si le commit local n’est pas celui d’origin/main ou si son image n’a pas été publiée. Sur le serveur, elle enchaîne ensuite ces étapes :

  1. mise à jour du dépôt, qui contient le fichier Compose et les scripts ;
  2. sauvegarde de la base et des médias ;
  3. écriture du tag du commit dans la configuration ;
  4. téléchargement des images, puis redémarrage. Les migrations en attente s’appliquent à ce moment-là ;
  5. attente d’une réponse du site, pendant 180 secondes au plus.

Le script distant tourne avec set -euo pipefail, donc une sauvegarde en échec arrête tout avant la bascule. Il est aussi passé en argument de ssh -n. Quand le script était envoyé par l’entrée standard, le docker compose exec de la sauvegarde consommait le reste du texte, et le déploiement s’arrêtait à mi-chemin sans aucune erreur.

# -n: stdin stays closed, so `docker compose exec` cannot swallow the script
ssh -n -i "$DEPLOY_SSH_KEY" "$DEPLOY_HOST" "$REMOTE_SCRIPT"

Les variables réservent un autre piège. L’attribut env_file transmet un fichier au conteneur, alors que les ${...} du fichier Compose sont résolus depuis le shell, un fichier .env ou l’option --env-file. Chaque commande passe donc --env-file .env.production, et une valeur obligatoire écrite ${POSTGRES_PASSWORD:?} empêche de démarrer avec un mot de passe vide.

Pour revenir en arrière, scripts/deploy.sh suivi d’un ancien commit redéploie l’image correspondante. Cela ne suffit que si le schéma n’a pas bougé, car une migration déjà appliquée n’est pas annulée par un changement d’image. Dans le cas contraire, il faut aussi restaurer la base, et c’est la raison d’être de la sauvegarde lancée juste avant chaque déploiement.

Que sauvegarder, et où ?

Une base restaurée sans ses fichiers donne des images cassées sur tout le site. La sauvegarde nocturne prend donc les deux, un dump PostgreSQL et une archive du volume des médias. pg_dump produit une copie cohérente même pendant que le site tourne, et ne bloque ni les lectures ni les écritures.

Un dump interrompu ne doit jamais ressembler à une vraie sauvegarde. Il est écrit sous un nom provisoire et renommé une fois complet, et pipefail fait échouer la commande si pg_dump s’arrête alors que gzip s’est terminé normalement.

set -euo pipefail
trap 'rm -f "$DB_FILE.partial"' EXIT

docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \
  | gzip > "$DB_FILE.partial"
mv "$DB_FILE.partial" "$DB_FILE"

Sur le VPS, les 7 derniers jeux sont conservés, comptés par nombre et non par âge. Une suite de nuits en échec ne fait ainsi jamais disparaître les dernières sauvegardes valides. Une copie locale ne protège cependant pas de la perte du serveur, d’où une seconde copie avec restic, un outil de sauvegarde qui chiffre et déduplique les données avant de les envoyer vers un stockage objet compatible S3. Les chemins restent identiques d’une nuit à l’autre, si bien que seuls les médias modifiés sont renvoyés.

La rétention distante garde 7 sauvegardes quotidiennes, 4 hebdomadaires et 6 mensuelles, avec forget --prune, car forget seul retire les snapshots sans libérer les données. Selon sa documentation, perdre le mot de passe du dépôt restic rend les données irrécupérables. Il doit donc exister ailleurs que sur le VPS, puisque c’est précisément quand celui-ci a disparu qu’on en a besoin.

Comment savoir qu’une sauvegarde est restaurable ?

Le script de restauration écrase des données, et il commence donc par refuser tout ce qui paraît anormal, avant la moindre suppression :

  • un fichier absent ou une archive gzip illisible, ou un snapshot restic sans médias ;
  • l’application encore en marche, qui continuerait à écrire pendant le rechargement ;
  • une base ou un volume qui contiennent déjà des données, sauf avec --force.

La base est ensuite rechargée dans une seule transaction, avec ON_ERROR_STOP. Si le dump échoue en cours de route, PostgreSQL annule tout et la base précédente reste en place au lieu d’un schéma à moitié reconstruit.

{
  echo 'drop schema public cascade; create schema public;'
  gunzip -c "$DB_SRC"
} | psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" --single-transaction

Un test d’intégration, lancé à la demande, rejoue le cycle sur une pile Docker jetable et un dépôt restic local. Il vérifie les échecs attendus (variable manquante, Postgres arrêté, mauvais mot de passe), la rotation, puis la restauration depuis les fichiers et depuis restic, en comparant les lignes de la base, les empreintes des médias et leurs droits.

Sur un serveur réel, la procédure prévoit une restauration de test après la mise en place, puis une par trimestre, dans un projet Compose distinct qui ne touche pas au site en ligne. La sauvegarde nocturne et le script de restauration sont en place sur le staging d’Atelier 33.

Qu’est-ce que Docker ne protège pas sur un VPS ?

Un conteneur isolé peut quand même se retrouver exposé à internet. Docker route le trafic des ports publiés avant l’application des règles d’ufw, et un service publié par erreur reste donc joignable malgré un pare-feu qui semble fermé. Dans le fichier Compose, seul Caddy, le reverse proxy qui gère aussi les certificats TLS, publie des ports ; l’application et PostgreSQL restent sur le réseau interne. Des règles dans la chaîne DOCKER-USER limitent en plus les ports atteignables, pour couvrir le jour où un port serait publié par mégarde.

Le réseau de l’hébergeur a aussi ses particularités. Sur le staging, hébergé chez OVH, le serveur ne joignait plus rien en IPv4 hors du réseau de l’hébergeur, alors que le SSH entrant fonctionnait. L’Edge Network Firewall est sans état, et ses règles n’autorisaient que le trafic entrant sur quelques ports TCP : les réponses aux connexions ouvertes par le VPS, qui reviennent sur un port éphémère, étaient jetées. La documentation d’OVH conseille justement d’autoriser le TCP avec l’option established. Ce pare-feu ne concernant que l’IPv4, un pare-feu sur la machine reste nécessaire pour l’IPv6.

Il reste un volume à traiter avec soin, celui de Caddy, qui contient les certificats et le compte ACME. Le perdre oblige à les réémettre, or Let’s Encrypt limite à 5 par semaine les certificats portant exactement les mêmes noms de domaine. À force de recréer ce volume, pendant des essais par exemple, on peut se retrouver bloqué quelques jours, d’où un docker compose down toujours sans -v.