Migrer n8n de MySQL vers PostgreSQL avant l'upgrade v2.0

n8n v2 supprime le support MySQL et MariaDB. Voici la procédure complète pour basculer ton instance vers PostgreSQL sans perdre tes credentials chiffrés.

Photo de Jean-Paul LOVISSOUKPO14 min de lectureMis à jour le 28 juillet 2026
Migration sécurisée de workflows d'une base MySQL orange vers une base PostgreSQL bleue

Pour migrer n8n de MySQL vers PostgreSQL, reste d’abord sur ta dernière version v1 fonctionnelle : arrête les écritures, sauvegarde la base et le volume n8n, exporte les entités, importe-les dans une base PostgreSQL vide, puis vérifie workflows, credentials et webhooks. L’upgrade vers n8n v2 vient seulement après ces contrôles.

Cette chronologie est obligatoire parce que n8n v2 ne prend plus en charge MySQL et MariaDB comme bases de stockage internes. Si tu as déjà tiré l’image v2 et que l’instance ne démarre plus, redescends sur ta dernière image v1 fonctionnelle avant d’exporter les données.

La procédure qui suit conserve la clé de chiffrement, prévoit un plan de retour arrière à chaque étape et utilise les commandes export:entities et import:entities fournies par n8n. À la fin, ton instance tournera sur PostgreSQL et tu pourras reprendre l’upgrade sans improviser un dimanche soir.

Pourquoi ton instance ne redémarrera plus en v2

Pour choisir la bonne procédure, il faut d’abord distinguer ce que la v2 retire réellement.

La page des breaking changes de la v2 l’annonce sans détour : n8n ne supporte plus MySQL et MariaDB comme backends de stockage. La checklist de migration v1.0 donnait déjà la raison : trop peu d’instances concernées pour un coût de maintenance permanent. La salle était vide, ils ont annulé la tournée.

Va faire un tour sur la documentation des bases supportées. Il ne reste que SQLite et PostgreSQL. MySQL n’y figure même plus comme option dépréciée : il a disparu de la page, comme un couplet coupé au montage.

Maintenant, réglons le malentendu qui fait paniquer du monde pour rien : le nœud MySQL et le backend MySQL portent exactement le même nom, et ne font pas du tout le même métier.

Le nœud MySQL, celui que tu poses dans un workflow pour interroger la base d’un client, continue de fonctionner normalement, la doc le confirme. Ce qui disparaît, c’est MySQL comme base interne de n8n, celle qui stocke tes workflows, ton historique d’exécutions et tes credentials. Deux rôles différents, un seul nom, beaucoup de panique évitable.

Et tant que tu restes en v1, rien n’est cassé. Ton instance tourne. Mais tu es sur une voie de garage : plus de nouveaux nœuds, plus de correctifs à terme, et une migration qui grossit en même temps que ta base. Autant la faire pendant que c’est encore toi qui choisis le moment. Une migration subie un lundi de rush, très peu pour moi.

Ce qu’il te faut

Cette distinction confirme que la base interne doit être migrée, alors que tes workflows peuvent rester inchangés. Avant d’arrêter l’instance, réunis donc la liste de courses suivante :

  • Une instance n8n en v1 qui démarre encore, sur MySQL ou MariaDB
  • Un accès shell au serveur et la possibilité d’exécuter des commandes dans le conteneur n8n
  • Ta valeur de N8N_ENCRYPTION_KEY, ou l’accès au fichier qui la contient
  • De la place disque pour faire cohabiter l’ancienne base et la nouvelle le temps de la bascule
  • Une fenêtre pendant laquelle personne ne déclenche de workflow

Le dernier point n’est pas une formalité. La migration se fait instance arrêtée. Si un webhook arrive pendant l’export, sa donnée n’existe nulle part : ni dans l’ancienne base, ni dans la nouvelle. Le facteur est passé, mais il n’y avait pas de boîte aux lettres.

L’audit avant de toucher à quoi que ce soit

La fenêtre d’arrêt et la clé de chiffrement sont prêtes. Il reste à vérifier que l’instance utilise réellement MySQL et à mesurer ce qui doit être déplacé. Avant de migrer quoi que ce soit, sache exactement sur quoi tu tournes. Sur pas mal d’instances héritées d’un prédécesseur, le DB_TYPE réel ne correspond pas à ce que tout le monde croit.

# Le type de base réellement utilisé par le conteneur
docker compose exec n8n printenv | grep '^DB_'

# La version exacte de n8n qui tourne
docker compose exec n8n n8n --version

Si DB_TYPE ressort à mysqldb, tu es concerné, et le reste de l’article est pour toi. Si rien ne ressort, tu es sur SQLite par défaut et cette migration ne te concerne pas. Garde quand même un œil sur la v2 : elle supprime aussi le vieux driver SQLite au profit du driver poolé.

n8n embarque d’ailleurs un rapport de compatibilité que presque personne n’a ouvert. Il vit dans Settings > Migration Report, réservé aux admins globaux. Il liste les workflows qui vont casser en v2 avec un niveau de sévérité par entrée, plus un second onglet pour les réglages de l’instance elle-même. Sur la mienne, au moment d’écrire ces lignes : 15 workflows compatibles sur 17, deux workflows à reprendre et quatre points d’instance à revoir, dont les nouvelles restrictions d’accès aux fichiers et le changement de comportement des nœuds Wait dans les sous-workflows. Ça ne remplace pas la migration de base, ça la complète : autant traiter les deux dans la même fenêtre d’arrêt.

Rapport de compatibilité n8n v2 : 15 workflows sur 17 déjà compatibles, deux problèmes de workflows et quatre problèmes d’instance

Profites-en pour regarder le poids de ta base et le volume d’exécutions conservées. Sans purge configurée, l’historique représente probablement l’essentiel du volume. Purger avant de migrer raccourcit tout, et soyons honnêtes deux secondes : personne n’a jamais rouvert une exécution d’il y a deux ans. C’est le carton à souvenirs de la cave, version JSON. Vide le carton, ta migration te dira merci.

La sauvegarde qui compte, et celle qui te laissera tomber

L’audit identifie le moteur, le volume et les incompatibilités à traiter. Ces informations déterminent ce qu’il faut sauvegarder avant la moindre modification.

Première chose à savoir : un mysqldump tout seul ne te sauvera pas. Il embarque bien tes tables, tes workflows et tes credentials. Mais tes credentials sont chiffrées, et la clé de déchiffrement n’est pas dans la base. Elle vit dans N8N_ENCRYPTION_KEY, soit passée en variable d’environnement, soit générée automatiquement au premier démarrage et rangée dans le fichier de config du volume n8n.

Un dump sans la clé, c’est un coffre-fort sans la combinaison. Joli, lourd, inutile. Commence donc par retrouver cette clé avant de lancer la moindre sauvegarde.

# La clé, si elle est passée en variable d'environnement
docker compose exec n8n printenv N8N_ENCRYPTION_KEY

# Sinon, elle a été générée et rangée dans le fichier de config
docker compose exec n8n cat /home/node/.n8n/config

Note cette clé ailleurs que sur le serveur, et je dis bien ailleurs. Sans elle, tu repars de zéro sur toutes tes credentials, une par une, à la main. Et bon courage pour retrouver le refresh token OAuth d’une intégration configurée il y a huit mois.

Fais donc les deux sauvegardes, dans cet ordre : le dump de la base MySQL, puis une copie du volume n8n, qui contient le fichier de config, donc potentiellement la clé. C’est ce duo qui te permet de revenir en arrière quoi qu’il arrive.

Si ta clé a été générée automatiquement et que tu n’as jamais copié le volume n8n, tu es à une commande docker volume rm de perdre définitivement toutes tes credentials. Fais cette copie maintenant. L’article t’attend, promis.

Sur la stratégie de sauvegarde à installer une fois la migration passée, j’y reviens en détail dans l’article sur l’auto-hébergement de la stack complète.

Sortir les données avec export:entities

Le dump et la copie du volume constituent ton filet de sécurité, mais ils ne sont pas le format utilisé pour changer de moteur. Pour transférer les entités n8n de MySQL vers PostgreSQL, la documentation officielle fait perdre du temps à tout le monde, alors autant te faire gagner le tien. La page des breaking changes te dit d’utiliser « the database migration tool ». Sans lien. Et si tu cherches « migration tool » dans la doc, tu tombes sur le rapport de compatibilité du chapitre précédent, qui ne migre pas la moindre donnée. L’outil s’appelle autrement et vit ailleurs, dans les commandes CLI : le duo export:entities et import:entities.

Ce duo a une propriété documentée noir sur blanc, et c’est elle qui règle tout notre problème : l’import accepte une base d’un type différent de celui de l’export. Tu exportes depuis MySQL, tu importes dans Postgres, et aucune ligne de SQL ne transite jamais d’un moteur à l’autre. C’est cadeau.

Moi, la première fois, j’ai fait l’inverse, et je te le raconte pour que tu n’aies pas à le vivre. J’avais converti le dump MySQL en SQL Postgres avec un outil de conversion. Le fichier s’importait presque. Les workflows passaient, les credentials aussi, et pourtant l’interface refusait d’en déchiffrer la moitié. J’ai perdu une soirée avant de comprendre que le problème n’était pas le chiffrement, mais une colonne booléenne mal convertie qui faisait échouer une lecture en silence. La commande officielle rend toute cette gymnastique inutile.

Arrête les workflows, puis exporte :

# On arrête tout ce qui pourrait écrire pendant l'export
docker compose stop n8n

# On relance juste le conteneur le temps de la commande, sans exposer le service
docker compose run --rm n8n n8n export:entities --outputDir=/home/node/.n8n/export

La documentation de export:entities le dit sans détour : toutes les informations sensibles sont visibles dans les fichiers produits. Tes tokens, tes mots de passe d’API, tes clés privées, en clair sur le disque. Ce dossier ne va ni dans un dépôt git, ni dans un dossier synchronisé sur un cloud, et il se supprime dès la migration terminée. C’est une loge d’artiste, pas une scène : personne d’autre n’y entre.

Si tu veux embarquer aussi l’historique des Data Tables, la commande accepte un drapeau dédié. Les Data Tables changent d’ailleurs pas mal de choses dans la manière de garder de l’état entre deux exécutions, j’en ai fait un article complet.

Le docker-compose côté PostgreSQL

L’export fournit maintenant les données indépendamment du moteur source. Il faut donc préparer une cible PostgreSQL capable de les recevoir sans encore changer de version n8n. Voici l’affiche du concert : Postgres en première partie, n8n en tête d’affiche. Et comme dans toute bonne salle, la première partie doit avoir fini ses balances avant que la star monte sur scène. C’est très exactement le rôle du healthcheck.

Les noms de variables viennent directement de la documentation des bases supportées, je ne les ai pas écrits de mémoire. Le compose suivant traduit ces choix en gardant n8n en v1 et en attendant que PostgreSQL soit réellement disponible.

services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: MOT_DE_PASSE_POSTGRES_ICI
      POSTGRES_DB: n8n
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      # n8n ne doit pas démarrer avant que la base accepte les connexions
      test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
      interval: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: docker.n8n.io/n8nio/n8n:VOTRE_VERSION_1X_ICI
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: MOT_DE_PASSE_POSTGRES_ICI
      # La même valeur qu'avant. C'est elle qui déchiffre les credentials.
      N8N_ENCRYPTION_KEY: TA_CLE_DE_CHIFFREMENT_ICI
    volumes:
      - n8n_data:/home/node/.n8n
    ports:
      - "5678:5678"

volumes:
  postgres_data:
  n8n_data:

Deux choix méritent une explication.

Le healthcheck couplé à depends_on: condition: service_healthy n’est pas du zèle. Sans lui, Docker considère Postgres comme prêt dès que le conteneur existe, alors que le moteur met encore quelques instants à accepter les connexions. n8n démarre, se cogne à une porte fermée, et selon la version, crashe ou repart en boucle. Tu passes ensuite une heure à chercher un problème de mot de passe qui n’existe pas.

Le tag d’image reste en v1 à ce stade. On migre la base d’abord, on upgrade ensuite. Faire les deux d’un coup, c’est changer de guitare et d’accordage au milieu du morceau : si ça sonne faux, impossible de savoir lequel des deux est en cause.

Si tu utilises un schéma Postgres dédié plutôt que public, la variable existe (DB_POSTGRESDB_SCHEMA), tout comme le jeu de variables SSL si ta base est distante. Le chemin parcouru peut maintenant se lire comme une seule séquence : sécuriser la source, déplacer les entités, puis valider avant l’upgrade.

Diagramme du processus : 1. Preparer, n8n v1 sur MySQL, Arret des workflows, Sauvegarde dump + volume, 2. Demenager, export:entities, Nouveau compose Postgres, import:entities.

L’import, et le piège de la base vide

PostgreSQL est maintenant sain et n8n v1 peut s’y connecter. Avant d’injecter l’export, la documentation pose une condition à lire deux fois : la base cible doit être vide avant l’import. Sinon, la commande refuse de tourner, sauf à lui passer --truncateTables, qui vide les tables au passage.

Ce drapeau t’obéit au doigt et à l’œil, y compris quand tu te trompes de base. Les machines n’ont aucun doute, c’est notre travail d’en avoir pour deux. Vérifie donc deux fois où pointe ton DB_POSTGRESDB_DATABASE avant de taper la commande, surtout si tu as déjà rempli la nouvelle instance lors d’un premier essai. Un --truncateTables lancé sur la mauvaise base, c’est le claquement de doigts de Thanos dans Avengers : un geste, tout disparaît, et aucune pierre magique ne ramène les disparus.

# On démarre uniquement Postgres, pour laisser n8n créer son schéma au premier lancement
docker compose up -d postgres

# Premier démarrage de n8n sur la base vide : il crée les tables, puis on l'arrête
docker compose up -d n8n
docker compose stop n8n

# Import des entités exportées
docker compose run --rm n8n n8n import:entities --inputDir=/home/node/.n8n/export

L’ordre a son importance, et il n’est pas intuitif. n8n doit avoir créé son schéma avant l’import, sinon la commande n’a aucune table où écrire. On monte d’abord la scène, ensuite on fait entrer le public. La doc considère ce point comme évident. Toi et moi savons maintenant qu’il ne l’est pas.

Vérifier avant de basculer en v2

Ton instance vient de changer de moteur : à toi de prouver que c’est toujours la même. Trois contrôles, dans l’ordre, et le premier qui échoue arrête tout.

D’abord, l’interface se charge et tes workflows sont là, au bon nombre. Interface vide ? L’import n’a pas écrit dans la base que lit n8n : vérifie que le conteneur qui a importé pointait sur la même configuration.

Ensuite, ouvre une credential existante. Si elle s’affiche sans erreur, ta clé de chiffrement est la bonne. C’est le test qui compte vraiment, et il prend le temps d’un clic.

Enfin, exécute à la main un workflow qui appelle une API authentifiée. Pas une transformation de texte en local : un vrai appel, une vraie credential, une vraie réponse. C’est la seule preuve que la chaîne complète tient debout.

Ce n’est qu’après ces trois feux verts que tu changes le tag d’image vers la v2 et que tu redémarres. Là, tu ressors le rapport de compatibilité et tu traites les workflows signalés.

Ce qui va te bloquer

Si les trois contrôles passent, le chemin nominal est terminé. Les échecs restants se rattachent généralement à l’une des étapes précédentes. Cinq problèmes reviennent tout le temps sur cette migration. Les voici, avec leur cause réelle et la sortie de secours.

Les credentials ne se déchiffrent plus. Le message est explicite, n8n indique que les credentials n’ont pas pu être déchiffrées et évoque une clé de chiffrement différente. C’est le symptôme le plus fréquent, et la cause est presque toujours la même : l’ancienne instance utilisait une clé générée automatiquement, rangée dans le fichier de config du volume, et le nouveau compose passe une clé différente en variable d’environnement. Récupère la valeur de l’ancien fichier de config et remets-la.

n8n redémarre en boucle au lancement. Regarde les logs du conteneur : si tu vois un refus de connexion vers l’hôte postgres, c’est le problème d’ordre de démarrage. Le healthcheck du compose plus haut le règle. Si tu ne veux pas de healthcheck, tu peux t’en sortir avec un restart: unless-stopped, mais tu auras des redémarrages sales à chaque reboot du serveur.

L’import refuse de tourner. La base cible n’est pas vide. La documentation des commandes CLI est explicite : l’import attend une base vierge, et le drapeau --truncateTables force le nettoyage. Attention si tu as déjà recréé des workflows à la main dans la nouvelle instance : ils partent avec.

L’interface se charge mais l’historique d’exécutions est vide. C’est le comportement attendu si tu n’as pas demandé l’export de l’historique. Ce n’est pas une perte de workflows. Si l’historique t’importe, il fallait le drapeau correspondant à l’export.

Le port répond mais l’authentification échoue. Sur les instances anciennes, on trouve encore dans le compose les variables d’authentification basique N8N_BASIC_AUTH_ACTIVE, N8N_BASIC_AUTH_USER et N8N_BASIC_AUTH_PASSWORD. Elles ne sont plus supportées depuis la v1, la gestion des utilisateurs intégrée les a remplacées : retire-les du compose, elles n’apportent que de la confusion au diagnostic.

Les limites de cette méthode

Les incidents précédents concernent une instance simple. Avant d’appliquer la même recette à une installation plus complexe, il faut en poser les limites. Elle ne convient pas telle quelle si tu tournes en mode queue avec des workers séparés. L’export et l’import se font bien depuis l’instance principale, mais tu dois arrêter les workers, et Redis conserve un état qui ne suit pas la base. Prévois de repartir sur une file propre.

Elle ne migre pas ce qui n’est pas dans la base : ton binaire stocké en filesystem, tes fichiers montés, tes variables d’environnement. Ça paraît évident écrit comme ça, ça l’est beaucoup moins à trois heures du matin quand le workflow qui lit un fichier local ne trouve rien.

Sur une base très volumineuse, l’export produit un dossier lourd et l’opération s’allonge. Purger l’historique d’exécutions avant l’export change complètement l’expérience.

Enfin, elle suppose que ton instance v1 démarre encore. Si tu as déjà upgradé en v2 et que plus rien ne se lance, il faut d’abord redescendre sur ta dernière image v1 fonctionnelle, ce qui suppose d’en connaître le tag exact. Encore une raison de ne jamais utiliser latest en production. Ce tag, garde-le pour tes scénarios de test.

Revenir en arrière si la migration échoue

Ces limites expliquent pourquoi l’ancienne base doit rester intacte pendant la période d’observation. Si l’un des contrôles échoue, le retour arrière est simple tant que tu ne l’as pas supprimée. Le diagramme suivant montre que seules la configuration et la base active reviennent alors à leur état initial.

Diagramme du processus : La bascule echoue, Remettre DB_TYPE mysqldb, Redemarrer sur image v1, Restaurer le dump MySQL, Restaurer le volume n8n, Instance a nouveau fonctionnelle, Base MySQL toujours en place ?.

C’est pour ça que je garde le conteneur MySQL en place plusieurs jours après la bascule, éteint mais présent, comme une roue de secours. Le coût en disque est dérisoire comparé au calme que ça procure.

Une fois que tout tourne depuis une semaine, tu supprimes le dossier d’export (celui qui contient tes secrets en clair), puis le conteneur et le volume MySQL.

La suite logique, c’est de profiter de cette base Postgres toute neuve pour reprendre le reste de la stack : reverse proxy, certificats, sauvegardes automatiques, supervision. C’est le programme de l’article sur l’auto-hébergement d’une stack d’automatisation complète, où je détaille aussi la politique de sauvegarde que j’applique sur mes VPS.

Et si tu tombes sur un cas que je n’ai pas couvert, notamment en mode queue, viens le raconter dans la communauté. C’est le genre de retour qui finit par enrichir l’article.

Questions fréquentes

Est-ce que le nœud MySQL de mes workflows continue de fonctionner en v2 ?+
Oui. La documentation le précise : seul le support de MySQL et MariaDB comme base de stockage interne de n8n disparaît. Le nœud MySQL, celui qui interroge la base de tes clients depuis un workflow, reste supporté normalement. Ce sont deux choses différentes qui portent le même nom.
Que se passe-t-il si je perds ma clé de chiffrement n8n ?+
Tes credentials deviennent illisibles, définitivement. La clé ne se trouve pas dans la base de données : elle est passée en variable N8N_ENCRYPTION_KEY, ou générée automatiquement et rangée dans le fichier de config du volume n8n. Sans elle, il faut reconfigurer chaque credential à la main.
Puis-je migrer directement de MySQL vers PostgreSQL avec un dump SQL ?+
Ce n'est pas la méthode recommandée. n8n fournit les commandes export:entities et import:entities, et l'import accepte une base d'un type différent de celle exportée. Tu évites ainsi toutes les différences de types entre les deux moteurs, qui provoquent des erreurs difficiles à diagnostiquer.
Faut-il migrer avant ou après l'upgrade vers n8n v2 ?+
Avant, toujours. La v2 ne sait plus lire une base MySQL, donc une instance déjà upgradée ne démarrera pas et ne pourra pas exporter ses données. Si tu as déjà upgradé, redescends sur ta dernière image v1 fonctionnelle, migre la base, puis remonte en v2.
Sujets :n8npostgresqlmysqldockerauto-hebergementmigration