Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.

Migrations Payload avec zero-downtime : la stratégie qu'on utilise sur Postgres

  • payload

Déployer des migrations de schéma Payload en production sans interruption de service sur PostgreSQL. Les patterns qui fonctionnent et ceux qui cassent.

Migrations Payload avec zero-downtime : la stratégie qu'on utilise sur Postgres

payload migrate applique les migrations au démarrage de l'application. Sans précaution, ça veut dire : base de données modifiée avant que le nouveau code soit actif, ou nouveau code actif sur une base de données pas encore migrée. Les deux causent des erreurs en prod.

Comment Payload gère les migrations

Payload s'appuie sur Drizzle pour générer et appliquer les migrations. Chaque payload migrate:create crée un fichier SQL dans /src/migrations/ qui décrit le delta entre l'état précédent et l'état courant du schéma.

Au démarrage, payload migrate applique séquentiellement toutes les migrations non encore appliquées, enregistrées dans la table payload_migrations.

# Créer une migration depuis les changements de collections
npx payload migrate:create

# Appliquer les migrations en attente
npx payload migrate

Le problème avec un déploiement classique : si l'application démarre et applique les migrations avant que les anciennes instances soient terminées, les deux versions de l'application cohabitent temporairement sur un schéma incohérent.

Le pattern expand-contract

La solution standard pour les migrations sans downtime s'appelle expand-contract. Elle décompose chaque migration en deux phases indépendantes et déployables séparément.

Phase 1, Expand : on ajoute les nouvelles colonnes, les nouveaux index, les nouvelles tables. L'ancien code continue de fonctionner parce qu'on n'a rien supprimé.

Phase 2, Contract : après validation que le nouveau code tourne correctement, on supprime les anciennes colonnes ou structures devenues inutiles.

Exemple concret : renommer une colonne

Scénario : on veut renommer la colonne name en full_name dans la collection Users.

Mauvaise approche (avec downtime potentiel) :

-- Migration directe, casse l'ancien code immédiatement
ALTER TABLE users RENAME COLUMN name TO full_name;

Approche expand-contract :

Migration 1, Expand :

-- Ajouter la nouvelle colonne
ALTER TABLE users ADD COLUMN full_name text;

-- Copier les données existantes
UPDATE users SET full_name = name WHERE full_name IS NULL;

-- Trigger pour maintenir la sync pendant la transition
CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS $$
BEGIN
  IF NEW.name IS DISTINCT FROM OLD.name THEN
    NEW.full_name := NEW.name;
  END IF;
  IF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
    NEW.name := NEW.full_name;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER sync_user_name_trigger
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();

On déploie le nouveau code qui lit full_name. L'ancien code lit encore name. Les deux fonctionnent.

Migration 2, Contract (déployée lors du prochain cycle, après validation) :

-- Supprimer le trigger de sync
DROP TRIGGER sync_user_name_trigger ON users;
DROP FUNCTION sync_user_name();

-- Supprimer l'ancienne colonne
ALTER TABLE users DROP COLUMN name;

Adapter Payload au pattern expand-contract

Payload génère les migrations automatiquement depuis les changements de collections. Le problème : si on renomme un champ dans la collection TypeScript, payload migrate:create génère une migration directe qui renomme la colonne, pas une migration expand.

Pour les changements sensibles, on ne laisse pas Payload générer la migration automatiquement. On crée les fichiers de migration manuellement :

# Créer un fichier de migration vide
npx payload migrate:create --name rename-user-name-to-full-name

Puis on écrit le SQL expand dans le fichier généré. Le fichier de migration Payload attend une fonction up et une fonction down :

// src/migrations/20260115_120000_rename-user-name-to-full-name.ts
import { MigrateUpArgs, MigrateDownArgs } from '@payloadcms/db-postgres';

export async function up({ db }: MigrateUpArgs): Promise<void> {
  await db.execute(sql`
    ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name text;
    UPDATE users SET full_name = name WHERE full_name IS NULL;
  `);
}

export async function down({ db }: MigrateDownArgs): Promise<void> {
  await db.execute(sql`
    ALTER TABLE users DROP COLUMN IF EXISTS full_name;
  `);
}

L'ordre de déploiement

Le séquencement est aussi important que le contenu des migrations.

Pour un déploiement sans downtime avec plusieurs instances :

  1. Appliquer la migration Expand (ajout de colonnes/tables)
  2. Déployer le nouveau code (compatible avec l'ancien et le nouveau schéma)
  3. Laisser les anciennes instances s'éteindre naturellement (drain des connexions)
  4. Valider que le nouveau code fonctionne correctement
  5. Appliquer la migration Contract (suppression des anciens éléments) lors du déploiement suivant

Sur Railway ou Render avec des déploiements rolling, on configure le health check pour que les nouvelles instances soient déclarées saines avant d'arrêter les anciennes. Les migrations s'appliquent sur la première nouvelle instance au démarrage, les autres instances du même déploiement ne les réappliquent pas grâce à la table payload_migrations.

Les migrations qui ne peuvent pas être zero-downtime

Certaines opérations sont intrinsèquement avec downtime sur PostgreSQL, même avec du soin :

  • Ajouter une contrainte NOT NULL sur une colonne existante avec des valeurs nulles
  • Ajouter une contrainte d'unicité sur une colonne avec des doublons
  • Modifier le type d'une colonne vers un type incompatible

Pour ces cas, on planifie une fenêtre de maintenance courte plutôt que de chercher à les rendre zero-downtime artificiellement.

Ce qu'on surveille

Les migrations Payload ne sont pas transactionnelles par défaut sur toutes les opérations DDL. Une migration qui échoue à mi-parcours peut laisser le schéma dans un état intermédiaire. On wrapper les migrations critiques dans des transactions explicites :

export async function up({ db }: MigrateUpArgs): Promise<void> {
  await db.transaction(async (trx) => {
    await trx.execute(sql`ALTER TABLE articles ADD COLUMN published_at timestamptz;`);
    await trx.execute(sql`UPDATE articles SET published_at = created_at;`);
    await trx.execute(sql`ALTER TABLE articles ALTER COLUMN published_at SET NOT NULL;`);
  });
}

La question qu'on n'a pas encore résolue : comment gérer le rollback automatique d'une migration Contract appliquée trop tôt, quand les données de l'ancienne colonne ont déjà été perdues ?

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Migrations Payload zero downtime sur PostgreSQL | Apogée Consult