×

n8n self-hosted sous Docker : le pourquoi du comment d’une stack de production

n8n self-hosted sous Docker : le pourquoi du comment d’une stack de production

🐘 PostgreSQL stockage Redis broker n8n main orchestrateur :5678 · :5679 n8n worker exécuteur 🛡 Runners isolation 🐳 Dockerfile image custom N8N SELF-HOSTED — STACK DE PRODUCTION 6 conteneurs · queue mode · runners isolés
La stack n8n complète — chaque brique a un rôle précis

Avant tout : c’est quoi n8n ?

Si tu tombes sur cet article sans connaître n8n, voilà en deux mots. n8n c’est une plateforme d’automation open source — un peu comme Zapier ou Make, mais que tu héberges toi-même. Tu connectes des services entre eux, tu crées des workflows, tu automatises des tâches répétitives. Et depuis la version 2, n8n a sérieusement monté en gamme : support natif des agents IA, des sub-workflows, une gestion des erreurs bien plus fine, et surtout une architecture qui tient la route en production.

Le self-hosting c’est l’intérêt principal. Pas de limite d’exécutions, pas de données qui partent sur des serveurs tiers, et surtout la possibilité d’utiliser des librairies Python ou JavaScript dans tes workflows — ce que les versions cloud ne permettent pas. Mais justement, pour que ça tourne bien en production, il faut pas se contenter du docker run basique. Et c’est exactement ce qu’on va voir ici.


On trouve des tas de tutos qui t’expliquent comment faire tourner n8n avec un simple docker run. Et effectivement, en deux minutes tu as quelque chose qui tourne. Le problème c’est que dès que tu commences à l’utiliser vraiment — des webhooks qui arrivent en parallèle, des workflows un peu lourds, du code Python — tout commence à ramer, à freezer, parfois à planter.

Dans cet article on va pas juste te coller un docker-compose et te dire bonne chance. On va t’expliquer pourquoi chaque brique est là, ce qui se passe si tu l’enlèves, et comment on est arrivé à cette architecture après l’avoir affinée dans le temps.


Ce qui se passe avec un n8n « basique »

Par défaut, n8n tourne dans un seul process, mono-thread, avec SQLite comme base de données. C’est parfait pour tester, découvrir l’outil, faire ses premiers workflows.

Mais très rapidement tu vas tomber sur des problèmes concrets :

👉 Tu lances deux workflows en même temps → l’un attend que l’autre finisse
👉 Tu exécutes un workflow un peu lourd → l’interface devient inutilisable pendant ce temps
👉 Un webhook arrive pendant qu’un autre tourne → il peut tout simplement être perdu
👉 Tu veux scaler → impossible, SQLite ne supporte pas l’accès concurrent depuis plusieurs process

C’est pas un bug de n8n. C’est juste que l’architecture par défaut n’est pas faite pour la production. Alors on va changer ça.

MODE SIMPLE — À ÉVITER n8n process unique UI · Webhooks · Exécution · SQLite mono-thread · tout bloque ensemble worker 1 ⏳ BLOQUÉ worker 2 ⏳ EN ATTENTE Pas de scaling · Tout plante ensemble MODE PRODUCTION — NOTRE STACK n8n main UI · Webhooks Redis queue jobs n8n worker exécution Runners code isolé PostgreSQL — Workflows · Credentials · Logs
Gauche : mode basique, tout tourne ensemble, tout bloque ensemble. Droite : mode production, chaque brique a un rôle précis.

La stack n8n de production : les 6 briques

Dans notre docker-compose, les conteneurs qui font tourner n8n sont au nombre de 6. Voilà pourquoi on en a besoin de chacun.

1. PostgreSQL — pas un luxe, une nécessité

SQLite — verrou fichier entier worker 1 BLOQUÉ ⏳ worker 2 EN ATTENTE worker 3 EN ATTENTE PostgreSQL — verrous par ligne worker 1 ✓ ÉCRIT worker 2 ✓ ÉCRIT worker 3 ✓ ÉCRIT
SQLite bloque tout le monde au moindre accès. PostgreSQL permet à chaque worker d’écrire indépendamment.

Le premier réflexe c’est de se dire « SQLite ça marche bien, pourquoi changer ». Le problème c’est que SQLite verrouille l’intégralité du fichier à chaque écriture. Un seul workflow qui écrit ses logs bloque tous les autres au même moment.

PostgreSQL lui travaille avec des verrous au niveau des lignes. Plusieurs workers peuvent écrire en parallèle sans se marcher dessus. Et surtout — et c’est la raison principale — le queue mode ne fonctionne pas avec SQLite. Tu peux pas scaler. Point.

postgres:
  image: postgres:16-alpine
  container_name: postgres-n8n
  environment:
    - POSTGRES_USER=${POSTGRES_USER:-n8n}
    - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    - POSTGRES_DB=${POSTGRES_DB:-n8n}
  volumes:
    - postgres_data:/var/lib/postgresql/data
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
    interval: 10s
    timeout: 5s
    retries: 5

Note le healthcheck — c’est important. n8n ne démarrera pas tant que PostgreSQL n’est pas prêt à accepter des connexions. On reviendra sur ça.

Dans le .env :

POSTGRES_USER=n8n
POSTGRES_PASSWORD=<mot_de_passe_genere>
POSTGRES_DB=n8n

Et côté n8n, la configuration qui bascule du SQLite vers PostgreSQL :

DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=postgres
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n}
DB_POSTGRESDB_USER=${POSTGRES_USER:-n8n}
DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}

⚠️ La valeur correcte est postgresdb et pas postgres. Cette erreur fait que n8n retombe silencieusement sur SQLite sans te prévenir. Beaucoup de gens passent des heures à chercher pourquoi leur queue mode ne fonctionne pas alors que c’est juste ça.

2. Redis — le système nerveux du queue mode

n8n main webhook reçu → push job push Redis — Bull Queue job #1042 — workflow A job #1043 — workflow B job #1044 — en attente… pull worker 1 ▶ job #1042 worker 2 ▶ job #1043 + worker 3 –scale 3
n8n main pousse les jobs dans Redis. Chaque worker libre prend le prochain job disponible — en parallèle.

Redis c’est pas un cache ici. C’est le broker de messages entre le process principal et les workers.

Le fonctionnement est simple : quand un workflow doit s’exécuter, n8n le pousse comme un job dans une queue Redis. Les workers surveillent cette queue, prennent le prochain job disponible et l’exécutent. C’est ce mécanisme qui permet à plusieurs workflows de tourner vraiment en parallèle.

Sans Redis, le queue mode est impossible. Sans queue mode, tu resteras toujours sur une architecture mono-process.

redis:
  image: redis:7-alpine
  container_name: redis-n8n
  command: >
    redis-server
    --requirepass ${REDIS_PASSWORD}
    --appendonly yes
    --maxmemory 512mb
    --maxmemory-policy allkeys-lru
  healthcheck:
    test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
    interval: 10s
    timeout: 5s
    retries: 5

Quelques points importants dans cette config :

  • --requirepass : on sécurise Redis avec un mot de passe, il n’y en a pas par défaut
  • --appendonly yes : persistance des données sur disque
  • --maxmemory 512mb avec allkeys-lru : Redis sait quoi supprimer si la mémoire est pleine

Dans le .env :

REDIS_PASSWORD=<mot_de_passe_genere>

Et côté n8n :

QUEUE_BULL_REDIS_HOST=redis
QUEUE_BULL_REDIS_PORT=6379
QUEUE_BULL_REDIS_PASSWORD=${REDIS_PASSWORD}
EXECUTIONS_MODE=queue

EXECUTIONS_MODE=queue — c’est la ligne qui bascule tout. Sans elle, PostgreSQL et Redis sont là pour rien.

3. n8n (main) — l’interface, les webhooks, le chef d’orchestre

Le process principal n8n a un rôle très précis dans une architecture queue : il gère l’UI, reçoit les webhooks, déclenche les triggers, et distribue les jobs dans Redis. Il n’exécute pas lui-même les workflows de production.

n8n:
  image: n8nio/n8n:latest
  container_name: n8n-main
  ports:
    - "${N8N_PORT:-5678}:5678"
    - "5679:5679"  # port broker pour les runners
  environment:
    - EXECUTIONS_MODE=queue
    - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
    - N8N_USER_MANAGEMENT_JWT_SECRET=${N8N_JWT_SECRET}
    - N8N_RUNNERS_ENABLED=true
    - N8N_RUNNERS_MODE=external
    - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
    - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
    - WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://localhost:5678/}
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

Deux variables critiques ici :

N8N_ENCRYPTION_KEY — c’est la clé qui chiffre tous tes credentials stockés en base. Si tu la perds, tous tes credentials deviennent irrécupérables. Génère-la une fois, stocke-la précieusement, ne la change jamais.

N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 — par défaut le broker écoute uniquement sur localhost. Comme les runners tournent dans leurs propres conteneurs, ils ne peuvent pas joindre localhost du conteneur n8n. Ce paramètre ouvre le broker sur toutes les interfaces du réseau Docker. Sans ça, les runners ne peuvent pas se connecter au broker et les Code nodes restent bloqués indéfiniment.

Le port 5679 est le port de communication entre le broker (n8n main) et les runners. Il faut l’exposer pour que les conteneurs sidecar puissent l’atteindre.

4. n8n-worker — l’exécuteur de workflows

Le worker c’est une instance n8n qui tourne en mode worker. Il ne fait qu’une chose : surveiller la queue Redis, prendre les jobs et les exécuter.

n8n-worker:
  image: n8nio/n8n:latest
  container_name: n8n-worker-1
  command: worker
  environment:
    - EXECUTIONS_MODE=queue
    - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
    - N8N_RUNNERS_ENABLED=true
    - N8N_RUNNERS_MODE=external
    - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
    - N8N_RUNNERS_TASK_BROKER_URI=http://n8n-main:5679
    - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}

Note que le worker pointe vers n8n-main:5679 pour le broker des runners. C’est le process principal qui joue le rôle de broker central — les runners des workers s’y connectent aussi.

Tu peux scaler horizontalement en ajoutant des workers :

docker compose up -d --scale n8n-worker=3

Chaque worker supplémentaire augmente le nombre de workflows pouvant tourner en parallèle.

5. Les Task Runners — la brique que personne n’explique

SANS RUNNERS — DANGEREUX n8n process credentials · env vars · DB · Code node 💥 script planté = toute l’instance down AVEC RUNNERS — ISOLÉ n8n process credentials · DB Runner sidecar Code node isolé ws 💥 runner mort — n8n continue toujours en vie
Le « blast radius » : sans runners tout le monde trinque. Avec runners, seul le sidecar meurt.

C’est là que ça devient vraiment intéressant, et c’est aussi la partie la plus mal documentée sur le net.

Le problème sans runners :

Quand tu utilises un Code node dans n8n — que ce soit du JavaScript ou du Python — ce code s’exécute. La question c’est : où et comment ?

Sans runners en mode externe, le code s’exécute directement dans le process n8n. Ça veut dire qu’un script Python mal écrit, une lib qui plante, un code qui boucle indéfiniment… peut faire tomber l’ensemble de ton instance n8n. Tout le monde dehors.

Pire : ce code tourne avec les mêmes droits que n8n lui-même. Il peut en théorie accéder à tes variables d’environnement, tes credentials, ta connexion base de données.

La solution : les runners en mode externe

Les runners sont des conteneurs séparés dont l’unique rôle est d’exécuter le code des Code nodes. Ils communiquent avec n8n via WebSocket, reçoivent le code à exécuter, le font tourner dans leur environnement isolé, et renvoient le résultat.

Si un runner plante → seul le runner meurt. n8n continue de tourner.

runners:
  build:
    context: .
    dockerfile: Dockerfile.runners
  container_name: n8n-runners
  environment:
    - N8N_RUNNERS_TASK_BROKER_URI=http://n8n-main:5679
    - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
    - N8N_NATIVE_PYTHON_RUNNER=true
    - N8N_RUNNERS_TASK_TIMEOUT=600
    - N8N_RUNNERS_STDLIB_ALLOW=re,hashlib,math,datetime,json,logging,typing,asyncio,http,urllib,base64
    - N8N_RUNNERS_EXTERNAL_ALLOW=pandas,numpy,spacy,sklearn,requests

Un runner par worker

Et là c’est le détail que même la doc officielle enterre dans une note : chaque worker a besoin de son propre runner sidecar. Ce n’est pas un runner global pour tout le monde.

C’est pourquoi on a un n8n-worker-runners dédié qui pointe spécifiquement vers n8n-worker-1:5679 :

n8n-worker-runners:
  image: n8n-runners
  container_name: n8n-worker-runners
  environment:
    - N8N_RUNNERS_TASK_BROKER_URI=http://n8n-worker-1:5679
    - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}

Le N8N_RUNNERS_AUTH_TOKEN est un secret partagé entre le broker et les runners pour s’authentifier. Sans ça, les runners sont rejetés à la connexion.

6. Le Dockerfile.runners custom — quand l’image officielle ne suffit pas

L’image officielle n8nio/runners embarque les runners JavaScript et Python de base. Mais si tu veux utiliser pandas, spacy, pdfplumber, pytesseract dans tes Code nodes, il faut les installer dans l’environnement Python du runner, pas dans n8n.

C’est là qu’on crée notre propre image :

FROM n8nio/runners:latest
USER root

RUN cd /opt/runners/task-runner-python && uv pip install \
  markitdown \
  langchain \
  yt-dlp \
  numpy \
  pandas \
  spacy \
  requests \
  pdfplumber \
  PyPDF2 \
  tika \
  pytesseract \
  pillow \
  pdf2image \
  charset-normalizer

COPY n8n-task-runners.json /etc/n8n-task-runners.json
USER runner

Deux points à noter :

  • On utilise uv pip et pas pip classique — c’est le gestionnaire de paquets Python ultra-rapide intégré dans l’image des runners. C’est volontaire.
  • On revient sur l’user runner après l’installation. Ne pas oublier cette ligne sinon le conteneur tourne en root, ce qui va à l’encontre de toute la logique de sécurité des runners.

Le fichier n8n-task-runners.json

Ce fichier configure le launcher — le process qui démarre les runners à la demande et gère leur cycle de vie. C’est lui qui définit comment lancer le runner JS et le runner Python, quelles variables d’environnement ils peuvent voir, et les flags de sécurité :

{
  "task-runners": [
    {
      "runner-type": "javascript",
      "command": "/usr/local/bin/node",
      "args": [
        "--disallow-code-generation-from-strings",
        "--disable-proto=delete",
        "/opt/runners/task-runner-javascript/dist/start.js"
      ],
      "health-check-server-port": "5681",
      "env-overrides": {
        "NODE_FUNCTION_ALLOW_BUILTIN": "*",
        "NODE_FUNCTION_ALLOW_EXTERNAL": "*",
        "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST": "0.0.0.0"
      }
    },
    {
      "runner-type": "python",
      "command": "/opt/runners/task-runner-python/.venv/bin/python",
      "args": ["-m", "src.main"],
      "health-check-server-port": "5682",
      "env-overrides": {
        "PYTHONPATH": "/opt/runners/task-runner-python",
        "N8N_RUNNERS_STDLIB_ALLOW": "*",
        "N8N_RUNNERS_EXTERNAL_ALLOW": "*"
      }
    }
  ]
}

Les flags --disallow-code-generation-from-strings et --disable-proto=delete sur le runner JS sont des mesures de sécurité qui empêchent certaines techniques d’injection de code. Les runners sont conçus pour exécuter du code utilisateur — autant les blinder.


Le docker-compose complet

Voici le docker-compose complet pour la partie n8n et toutes ses dépendances. Le reste du stack (Ollama, Supabase, Langfuse…) est volontairement exclu pour rester focalisé sur n8n.

services:

  # ═══════════════════════════════════════════════════════════════════
  # POSTGRESQL - Base de données pour n8n
  # ═══════════════════════════════════════════════════════════════════
  postgres:
    image: postgres:16-alpine
    container_name: postgres-n8n
    ports:
      - "${POSTGRES_PORT:-5432}:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=${POSTGRES_USER:-n8n}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB:-n8n}
    restart: unless-stopped
    networks:
      - ai-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ═══════════════════════════════════════════════════════════════════
  # REDIS - Queue pour scalabilité n8n
  # ═══════════════════════════════════════════════════════════════════
  redis:
    image: redis:7-alpine
    container_name: redis-n8n
    ports:
      - "${REDIS_PORT:-6379}:6379"
    volumes:
      - redis_data:/data
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --appendonly yes
      --maxmemory 512mb
      --maxmemory-policy allkeys-lru
    restart: unless-stopped
    networks:
      - ai-network
    healthcheck:
      test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ═══════════════════════════════════════════════════════════════════
  # N8N - Plateforme d'automation (interface principale)
  # ═══════════════════════════════════════════════════════════════════
  n8n:
    image: n8nio/n8n:latest
    container_name: n8n-main
    ports:
      - "${N8N_PORT:-5678}:5678"
      - "5679:5679"
    volumes:
      - n8n_data:/home/node/.n8n
      - ./n8n-custom:/home/node/.n8n/custom
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n}
      - DB_POSTGRESDB_USER=${POSTGRES_USER:-n8n}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - QUEUE_BULL_REDIS_HOST=redis
      - QUEUE_BULL_REDIS_PORT=6379
      - QUEUE_BULL_REDIS_PASSWORD=${REDIS_PASSWORD}
      - EXECUTIONS_MODE=queue
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_USER_MANAGEMENT_JWT_SECRET=${N8N_JWT_SECRET}
      - N8N_HOST=0.0.0.0
      - N8N_PORT=5678
      - N8N_PROTOCOL=http
      - WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://localhost:5678/}
      - N8N_SECURE_COOKIE=${N8N_SECURE_COOKIE:-false}
      - N8N_RUNNERS_ENABLED=true
      - N8N_RUNNERS_MODE=external
      - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
      - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_TASK_TIMEOUT=600
      - N8N_RUNNERS_STDLIB_ALLOW=*
      - N8N_RUNNERS_EXTERNAL_ALLOW=pandas,numpy,spacy,sklearn,requests,langchain,pdfplumber,PyPDF2,tika,charset-normalizer
      - N8N_METRICS=true
      - N8N_LOG_LEVEL=info
      - TZ=${TZ:-Europe/Paris}
      - EXECUTIONS_TIMEOUT=-1
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - ai-network
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:5678/healthz"]
      interval: 30s
      timeout: 10s
      retries: 3

  # ═══════════════════════════════════════════════════════════════════
  # N8N WORKER - Exécution des workflows
  # ═══════════════════════════════════════════════════════════════════
  n8n-worker:
    image: n8nio/n8n:latest
    container_name: n8n-worker-1
    command: worker
    volumes:
      - n8n_data:/home/node/.n8n
      - ./n8n-custom:/home/node/.n8n/custom
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=${POSTGRES_DB:-n8n}
      - DB_POSTGRESDB_USER=${POSTGRES_USER:-n8n}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - QUEUE_BULL_REDIS_HOST=redis
      - QUEUE_BULL_REDIS_PORT=6379
      - QUEUE_BULL_REDIS_PASSWORD=${REDIS_PASSWORD}
      - EXECUTIONS_MODE=queue
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - TZ=${TZ:-Europe/Paris}
      - EXECUTIONS_TIMEOUT=-1
      - N8N_RUNNERS_ENABLED=true
      - N8N_RUNNERS_MODE=external
      - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
      - N8N_RUNNERS_TASK_BROKER_URI=http://n8n-main:5679
      - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_TASK_TIMEOUT=600
      - N8N_RUNNERS_STDLIB_ALLOW=*
      - N8N_RUNNERS_EXTERNAL_ALLOW=pandas,numpy,spacy,sklearn,requests,langchain,pdfplumber,PyPDF2,tika,charset-normalizer
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      n8n:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - ai-network

  # ═══════════════════════════════════════════════════════════════════
  # N8N WORKER RUNNERS - Sidecar runner dédié au worker
  # Un runner par worker — c'est la règle, pas une option
  # ═══════════════════════════════════════════════════════════════════
  n8n-worker-runners:
    image: n8n-runners
    container_name: n8n-worker-runners
    environment:
      - N8N_RUNNERS_TASK_BROKER_URI=http://n8n-worker-1:5679
      - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_MAX_OLD_SPACE_SIZE=4096
      - N8N_RUNNERS_TASK_TIMEOUT=600
      - N8N_NATIVE_PYTHON_RUNNER=true
      - N8N_RUNNERS_STDLIB_ALLOW=*
      - N8N_RUNNERS_EXTERNAL_ALLOW=pandas,numpy,spacy,sklearn,requests
    depends_on:
      - n8n-worker
    networks:
      - ai-network
    restart: unless-stopped

  # ═══════════════════════════════════════════════════════════════════
  # RUNNERS - Sidecar pour le main (exécutions manuelles UI)
  # ═══════════════════════════════════════════════════════════════════
  runners:
    build:
      context: .
      dockerfile: Dockerfile.runners
    container_name: n8n-runners
    volumes:
      - ./n8n-task-runners.json:/etc/n8n-task-runners.json:ro
    environment:
      - N8N_RUNNERS_TASK_BROKER_URI=http://n8n-main:5679
      - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
      - N8N_NATIVE_PYTHON_RUNNER=true
      - N8N_RUNNERS_TASK_TIMEOUT=600
      - N8N_RUNNERS_STDLIB_ALLOW=re,hashlib,math,datetime,json,logging,typing,asyncio,http,urllib,base64
      - N8N_RUNNERS_EXTERNAL_ALLOW=pandas,numpy,spacy,sklearn,requests
      - N8N_RUNNERS_MAX_OLD_SPACE_SIZE=${N8N_RUNNERS_MAX_OLD_SPACE_SIZE:-4096}
    restart: unless-stopped
    networks:
      - ai-network

volumes:
  postgres_data:
    name: postgres_data
  redis_data:
    name: redis_data
  n8n_data:
    name: n8n_data

networks:
  ai-network:
    name: ai-network
    driver: bridge

Le fichier .env et la gestion des secrets

Un fichier .env bien structuré c’est la base. Toutes les valeurs sensibles doivent y être et ne jamais se retrouver en clair dans le docker-compose.

Voici les variables spécifiques à n8n :

# ═══════════════════════════════════════════════════════
# POSTGRESQL (n8n)
# ═══════════════════════════════════════════════════════
POSTGRES_PORT=5432
POSTGRES_USER=n8n
POSTGRES_PASSWORD=CHANGEZ_MOI
POSTGRES_DB=n8n

# ═══════════════════════════════════════════════════════
# REDIS (queue n8n)
# ═══════════════════════════════════════════════════════
REDIS_PORT=6379
REDIS_PASSWORD=CHANGEZ_MOI

# ═══════════════════════════════════════════════════════
# N8N
# ═══════════════════════════════════════════════════════
N8N_PORT=5678

# NE JAMAIS PERDRE CETTE CLÉ — tous tes credentials en dépendent
N8N_ENCRYPTION_KEY=CHANGEZ_MOI

# Secret JWT pour la gestion des sessions
N8N_JWT_SECRET=CHANGEZ_MOI

# URL publique pour que les webhooks soient joignables de l'extérieur
N8N_WEBHOOK_URL=https://n8n.ton-domaine.com/

# false si tu es en HTTP (LAN / accès local)
N8N_SECURE_COOKIE=false

TZ=Europe/Paris

# Token partagé entre le broker et les runners
N8N_RUNNERS_AUTH_TOKEN=CHANGEZ_MOI

Cybersécurité — les points à ne pas négliger

Parce qu’un n8n mal sécurisé c’est une porte d’entrée sur tout ton réseau. Voici les points critiques à avoir en tête.

🔑 ENCRYPTION KEY Chiffre TOUS tes credentials Perdue = irrécupérables Ne jamais changer 🚪 PORTS EXPOSÉS Redis · Postgres Retirer les ports si pas besoin Réseau Docker interne suffit 🍪 SECURE COOKIE false → HTTP local true → HTTPS Reverse proxy = true obligatoire 📁 .ENV HORS GIT .gitignore : .env .env.* Commit accidentel = tout régénérer
Les 4 points de sécurité critiques d’une installation n8n en production.

La N8N_ENCRYPTION_KEY : ta responsabilité numéro 1

Cette clé chiffre tous les credentials que tu stockes dans n8n — tokens API, mots de passe, clés SSH, tout. Elle n’est pas stockée dans PostgreSQL, elle est sur le volume n8n.

👉 Règle absolue : génère-la une fois au démarrage, stocke-la dans un gestionnaire de mots de passe (Bitwarden, KeePass…), et ne la change jamais. Si tu la changes ou si tu la perds, tous tes credentials deviennent des données chiffrées illisibles. Tu dois tout re-saisir à la main.

Redis et PostgreSQL non exposés

Si tu n’as pas besoin d’accéder à Redis ou PostgreSQL depuis l’extérieur des conteneurs, retire carrément les lignes ports dans le docker-compose. Moins de surface exposée, c’est moins de risques. Les conteneurs Docker communiquent entre eux via le réseau interne sans avoir besoin d’exposer les ports sur l’hôte.

N8N_SECURE_COOKIE et HTTPS

Si tu exposes n8n sur internet derrière un reverse proxy (Nginx, Traefik, Caddy), passe N8N_SECURE_COOKIE=true. Ce flag force les cookies de session à être transmis uniquement en HTTPS. En HTTP local, tu peux le laisser à false.

Les runners : le mode externe c’est pas juste du confort, c’est une vraie barrière de sécurité

Quand quelqu’un écrit du code dans un Code node n8n — que ce soit toi ou un utilisateur de ton instance — ce code ne sait pas qu’il tourne dans n8n. Il s’exécute dans un conteneur séparé, sans accès à ta base de données, sans accès à tes variables d’environnement n8n, sans accès au réseau hôte.

Si tu laisses les runners en mode interne (le mode par défaut), le code utilisateur tourne dans le même process que n8n. C’est exactement comme si tu laissais un inconnu ouvrir un terminal sur ton serveur. Le mode externe crée une vraie frontière d’isolation — pas une option de confort, une décision de sécurité.

Le N8N_RUNNERS_AUTH_TOKEN est le secret partagé qui permet aux runners de s’authentifier auprès du broker. Sans lui, n’importe quel process qui essaierait de se connecter au broker serait rejeté.

Le .env ne va jamais dans Git

Ton .env contient des secrets en clair. Il doit impérativement être dans ton .gitignore. Si tu pousses ce fichier dans un dépôt public — même une seconde — considère tous tes secrets compromis et régénère-les immédiatement.

🔒 Les flags --disallow-code-generation-from-strings et --disable-proto=delete dans le n8n-task-runners.json bloquent des techniques d’injection de code connues. Les runners exécutent du code utilisateur — autant les blinder au maximum.


Le script de démarrage — génération automatique des secrets

Plutôt que de générer les clés à la main et de les coller dans le .env, voici un script bash qui s’en charge automatiquement. Il détecte les variables vides ou marquées CHANGEZ_MOI et génère une valeur sécurisée avec openssl rand.

#!/bin/bash
set -e

GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m'

info()    { echo -e "${GREEN}[INFO]${NC} $1"; }
warning() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error()   { echo -e "${RED}[ERROR]${NC} $1"; }

clear
echo -e "${BLUE}══════════════════════════════════════════════════════════${NC}"
echo -e "${BLUE}  N8N STACK - Initialisation et démarrage                 ${NC}"
echo -e "${BLUE}══════════════════════════════════════════════════════════${NC}"
echo ""

# Vérifications des prérequis
info "Vérification des prérequis..."
command -v docker >/dev/null 2>&1 || { error "Docker requis"; exit 1; }
docker compose version >/dev/null 2>&1 || { error "Docker Compose requis"; exit 1; }
info "✓ Prérequis OK"

# Génération des secrets si nécessaire
declare -A secrets=(
    [N8N_ENCRYPTION_KEY]=64
    [N8N_JWT_SECRET]=64
    [POSTGRES_PASSWORD]=32
    [REDIS_PASSWORD]=32
    [N8N_RUNNERS_AUTH_TOKEN]=64
)

for key in "${!secrets[@]}"; do
    val=$(grep -E "^$key=" .env 2>/dev/null | cut -d'=' -f2-)
    if [ -z "$val" ] || [ "$val" == "CHANGEZ_MOI" ]; then
        length=${secrets[$key]}
        newval=$(openssl rand -hex $((length/2)))
        sed -i "s|^$key=.*|$key=$newval|" .env
        info "✓ $key généré automatiquement"
    fi
done

# Build de l'image custom des runners
info "Build de l'image n8n-runners custom..."
docker compose build runners
info "✓ Image runners construite"

# Démarrage de la stack
info "Démarrage de la stack..."
docker compose up -d

sleep 15

# Vérification de l'état des conteneurs
info "État des conteneurs :"
docker compose ps

echo ""
info "✓ Stack démarrée !"
echo ""
info "Accès aux services :"
echo "  - n8n          : http://localhost:5678"
echo ""
info "Pour scaler les workers :"
echo "  docker compose up -d --scale n8n-worker=3"
echo ""
info "Commandes utiles :"
echo "  - Logs :    docker compose logs -f n8n"
echo "  - Arrêter : docker compose down"

Le script est idempotent — tu peux le lancer plusieurs fois sans problème. S’il voit que la valeur est déjà définie, il ne la touche pas.


Mettre à jour les librairies du runner — sans tout casser

C’est l’une des opérations les plus courantes une fois la stack en place. Tu découvres une nouvelle lib Python que tu veux utiliser dans tes Code nodes — httpx, beautifulsoup4, openai, peu importe. Voilà la procédure complète.

01 Dockerfile.runners ajouter la lib 02 whitelist docker-compose EXTERNAL_ALLOW 03 build –no-cache rebuild image 04 force-recreate runners sans couper n8n
4 étapes pour ajouter une lib. n8n et les workers restent up pendant tout le processus.

Étape 1 : modifier le Dockerfile.runners

Ouvre ton Dockerfile.runners et ajoute ta librairie dans le bloc uv pip install :

RUN cd /opt/runners/task-runner-python && uv pip install \
  markitdown langchain yt-dlp numpy pandas \
  spacy requests pdfplumber PyPDF2 tika \
  pytesseract pillow pdf2image charset-normalizer \
  httpx \           # ← nouvelle lib ajoutée ici
  beautifulsoup4    # ← et une autre

Étape 2 : mettre à jour la whitelist si nécessaire

Si tu veux que ta lib soit utilisable dans les Code nodes, elle doit aussi être dans la whitelist N8N_RUNNERS_EXTERNAL_ALLOW côté n8n et n8n-worker dans le docker-compose. Dans notre config on a mis * — tout est autorisé. En production tu peux restreindre à la liste exacte des libs dont tu as besoin.

Étape 3 : rebuilder l’image avec –no-cache

Le --no-cache est important ici. Sans lui, Docker peut réutiliser des couches cachées et ne pas réinstaller les nouvelles libs. Avec des Dockerfiles qui font du pip install, le cache peut jouer des tours.

docker compose build --no-cache runners

Étape 4 : redémarrer les runners sans tout couper

Bonne nouvelle, tu n’as pas besoin de couper n8n ou les workers pour ça. Les runners sont des sidecars indépendants. Tu peux les redémarrer seuls :

# --force-recreate garantit que la nouvelle image est utilisée
docker compose up -d --force-recreate runners n8n-worker-runners

# Vérification : la lib est bien dans le runner ?
docker exec n8n-runners \
  /opt/runners/task-runner-python/.venv/bin/pip list | grep httpx

Si tu vois ta lib dans la liste → c’est bon. Tu peux maintenant l’utiliser dans n’importe quel Code node Python de tes workflows, sans redémarrer n8n.


Résumé de l’architecture

Pour bien visualiser comment tout ça s’articule :

FLUX DE DONNÉES — STACK N8N PRODUCTION Webhook HTTP Trigger schedule Exécution UI n8n main :5678 — UI & Webhooks :5679 — Broker runners → push jobs Redis → reçoit webhooks N’exécute PAS les workflows Redis Bull Queue · broker port :6379 first-come first-served push job n8n worker pull jobs Redis exécute workflows broker :5679 scalable –scale n=3 pull job Runners (worker) n8n-worker-runners JS runner :5681 Python :5682 isolé · sandboxé Code Runners (main) exécutions manuelles depuis l’UI → n8n-main:5679 PostgreSQL Workflows · Credentials Logs · Résultats orchestration exécution code lecture/écriture DB distribution jobs
Vue complète de l’architecture. Chaque flèche représente une dépendance réelle — si un maillon manque, quelque chose ne fonctionne pas, souvent en silence.

Chaque flèche représente une dépendance réelle. Si un maillon manque, quelque chose ne fonctionne pas — et souvent en silence, ce qui rend le diagnostic difficile.


Conclusion

Un n8n de production sous Docker c’est 6 conteneurs avec des rôles bien distincts. Pas parce qu’on aime la complexité, mais parce que chaque brique répond à un problème réel qu’on rencontre dès qu’on dépasse le stade « test sur le coin du bureau ».

La logique c’est :

  • PostgreSQL → pour la concurrence et le queue mode
  • Redis → pour distribuer le travail entre workers
  • n8n main → uniquement l’orchestration, pas l’exécution
  • n8n-worker → uniquement l’exécution
  • Runners → isoler le code utilisateur du reste
  • Dockerfile custom → avoir les librairies Python dont tu as besoin

Une fois que t’as compris le rôle de chaque brique, le docker-compose devient beaucoup plus lisible et surtout beaucoup plus facile à maintenir et à faire évoluer.

Laisser un commentaire