assets/.env.queue.example
# .env for QUEUE mode. Copy to `.env` and fill in.
# NEVER commit the real .env. NEVER copy values from another n8n instance —
# generate fresh secrets on THIS box (see security.md for the openssl commands).
# --- where the project lives on the host (absolute path) ---
# MUST be the directory you put docker-compose.yml + init-data.sh in and run `docker compose` from.
DATA_FOLDER=/opt/n8n
# --- your domain: final URL becomes https://SUBDOMAIN.DOMAIN_NAME/ ---
DOMAIN_NAME=example.com
SUBDOMAIN=n8n
# --- email used by Caddy for the TLS certificate (Let's Encrypt) ---
SSL_EMAIL=you@example.com
# --- timezone for Schedule/Cron nodes (e.g. Europe/Warsaw, America/New_York) ---
GENERIC_TIMEZONE=Etc/UTC
# --- pin the n8n image (recommended) or leave "stable" ---
N8N_IMAGE_TAG=stable
# --- ENCRYPTION KEY: generate with `openssl rand -base64 32`. ---
# Used by the main process AND every worker — they must all read this SAME value.
# Lose it and every saved credential becomes undecryptable. Back it up off-box.
N8N_ENCRYPTION_KEY=REPLACE_WITH_openssl_rand_base64_32
# --- Postgres ---
# Superuser (admin) — used only to bootstrap the database + the non-root user.
POSTGRES_USER=postgres
POSTGRES_PASSWORD=REPLACE_WITH_openssl_rand_base64_24
POSTGRES_DB=n8n
# Non-root user that n8n actually connects with (created by init-data.sh).
POSTGRES_NON_ROOT_USER=n8n
POSTGRES_NON_ROOT_PASSWORD=REPLACE_WITH_openssl_rand_base64_24
# --- optional backend modules (comma-separated; off unless listed) ---
# Some n8n features ship as opt-in backend modules — currently `agents`.
# In QUEUE mode do NOT set this here: put it in the `x-n8n-env` anchor of
# docker-compose.yml so the main AND every worker get it. A module enabled on
# the main only looks fine in the UI and fails at runtime on a worker.
# See QUEUE_MODE.md → "Optional modules".
assets/.env.single.example
# .env for SINGLE / regular mode. Copy to `.env` and fill in.
# NEVER commit the real .env. NEVER copy values from another n8n instance —
# generate fresh secrets on THIS box (see security.md for the openssl commands).
# --- where the project lives on the host (absolute path) ---
# MUST be the directory you put docker-compose.yml in and run `docker compose` from.
DATA_FOLDER=/opt/n8n
# --- your domain: final URL becomes https://SUBDOMAIN.DOMAIN_NAME/ ---
DOMAIN_NAME=example.com
SUBDOMAIN=n8n
# --- email used by Caddy for the TLS certificate (Let's Encrypt) ---
SSL_EMAIL=you@example.com
# --- timezone for Schedule/Cron nodes (e.g. Europe/Warsaw, America/New_York) ---
GENERIC_TIMEZONE=Etc/UTC
# --- pin the n8n image (recommended) or leave "stable" ---
N8N_IMAGE_TAG=stable
# --- ENCRYPTION KEY: generate with `openssl rand -base64 32`. ---
# This encrypts all stored credentials. If you lose it, every saved credential
# becomes undecryptable. Back it up somewhere safe and OUTSIDE this box.
N8N_ENCRYPTION_KEY=REPLACE_WITH_openssl_rand_base64_32
# --- optional backend modules (comma-separated; off unless listed) ---
# Some n8n features ship as opt-in backend modules — currently `agents`.
# The compose lists n8n's env vars explicitly, so setting this here alone does
# nothing — uncomment the matching line in docker-compose.yml, then
# `docker compose up -d --no-deps n8n`.
# N8N_ENABLED_MODULES=agents
assets/Caddyfile
# Caddy reverse proxy for n8n — automatic HTTPS via Let's Encrypt/ZeroSSL.
# Domain-free: the site address is filled from the caddy service's environment
# ({$N8N_SUBDOMAIN}.{$N8N_DOMAIN}), which compose populates from .env. Nothing
# here is host- or client-specific, so this file is safe to commit.
#
# Requirements for the cert to issue: the domain's DNS A record must already
# point at this server, and ports 80 + 443 must be reachable from the internet.
{
# ACME contact address (optional but recommended). Empty = anonymous issuance.
email {$SSL_EMAIL}
}
{$N8N_SUBDOMAIN}.{$N8N_DOMAIN} {
reverse_proxy n8n:5678 {
# Stream responses immediately — keeps the editor's live push channel
# (SSE / websockets) responsive instead of buffering.
flush_interval -1
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {http.request.header.X-Forwarded-For}
}
# Baseline security headers.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
assets/docker-compose.queue.yml
# n8n — QUEUE mode (main + Redis + Postgres + N workers), Caddy auto-TLS.
# Use for horizontal scale / high execution volume. Requires Postgres (SQLite
# is not supported in queue mode). The SAME N8N_ENCRYPTION_KEY must reach the
# main AND every worker — the `x-n8n-env` anchor guarantees that here.
#
# Secrets come ONLY from the .env file in this directory — never inline them here.
# main/workers/postgres/redis stay on the private network; only Caddy is public.
# ---------------------------------------------------------------------------
# THE COMMON ENVIRONMENT — declared ONCE, shared by the main and every worker.
#
# This is the single most important structural rule in this file: main and
# workers must agree on how n8n BEHAVES (database, queue, encryption key,
# enabled modules, binary-data mode). They differ only in that the main also
# serves the public UI. So anything behavioural goes HERE, and the main merges
# this block and adds its public-URL vars on top — see the `n8n` service.
#
# If you add a behavioural flag to the main's own `environment:` instead of
# here, the workers silently keep the old behaviour. Because workers are what
# actually execute your workflows, the failure shows up at runtime, in one node,
# long after deploy. Add it here.
# ---------------------------------------------------------------------------
x-n8n-env: &n8n-env
# --- database (shared Postgres) ---
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: "5432"
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
DB_POSTGRESDB_USER: ${POSTGRES_NON_ROOT_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_NON_ROOT_PASSWORD}
# --- queue (Redis / Bull) ---
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
QUEUE_BULL_REDIS_PORT: "6379"
QUEUE_HEALTH_CHECK_ACTIVE: "true"
OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS: "true"
# --- the encryption key: identical for main + all workers; BACK IT UP ---
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
# --- timezone ---
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
TZ: ${GENERIC_TIMEZONE}
NODE_ENV: production
# --- secure defaults ---
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_PERSONALIZATION_ENABLED: "false"
N8N_HIRING_BANNER_ENABLED: "false"
N8N_BLOCK_ENV_ACCESS_IN_NODE: "true"
N8N_RUNNERS_ENABLED: "true"
# Python Code nodes (and real code isolation) need task runners in EXTERNAL mode:
# one n8nio/runners sidecar PER WORKER, same tag. That replaces `replicas`. See TASK_RUNNERS.md.
# Queue mode does NOT support filesystem binary mode — binary data lives in
# Postgres (`database`) so main + workers all see it. S3/Azure external
# storage needs an Enterprise license — see QUEUE_MODE.md.
N8N_DEFAULT_BINARY_DATA_MODE: database
# Pruning is what keeps the DB bounded now that executions AND binary data
# live in Postgres (on by default upstream; made explicit + raised count).
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "336"
EXECUTIONS_DATA_PRUNE_MAX_COUNT: "50000"
# --- optional backend modules (opt-in; NOT enabled by default upstream) ---
# e.g. `agents` for the Agents feature. Uncomment on main AND workers by
# virtue of living here — a module enabled only on the main makes the
# feature visible in the UI while every execution that touches it fails on
# a worker. See QUEUE_MODE.md → "Optional modules".
# N8N_ENABLED_MODULES: agents
# Image/volumes/networking shared by the main and the workers.
x-n8n: &n8n
image: docker.n8n.io/n8nio/n8n:${N8N_IMAGE_TAG:-stable}
restart: unless-stopped
environment: *n8n-env
volumes:
- n8n_storage:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- n8n_net
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- N8N_SUBDOMAIN=${SUBDOMAIN}
- N8N_DOMAIN=${DOMAIN_NAME}
- SSL_EMAIL=${SSL_EMAIL}
volumes:
- caddy_data:/data
- caddy_config:/config
- ${DATA_FOLDER}/caddy_config/Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- n8n
networks:
- n8n_net
postgres:
# n8n supports "actively maintained" Postgres versions — 16 is fine through ~2028;
# revisit the pin when it nears EOL.
image: postgres:16
restart: unless-stopped
environment:
# POSTGRES_USER/PASSWORD = the superuser; the non-root pair is what n8n
# actually connects with (created by init-data.sh on first boot).
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
- POSTGRES_NON_ROOT_USER
- POSTGRES_NON_ROOT_PASSWORD
volumes:
- db_storage:/var/lib/postgresql/data
- ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- n8n_net
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_storage:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
networks:
- n8n_net
# The main process: editor UI, REST API, triggers/timers, receives webhooks.
# It enqueues executions; it does not run them (workers do). It merges the
# common environment and adds ONLY the public-URL / reverse-proxy vars —
# those are the one legitimate reason for the main to differ from a worker.
n8n:
<<: *n8n
# No `ports:` — reached only through Caddy.
environment:
<<: *n8n-env
# public URL / reverse proxy (main only — workers serve no UI)
N8N_HOST: ${SUBDOMAIN}.${DOMAIN_NAME}
N8N_PORT: "5678"
N8N_PROTOCOL: https
N8N_EDITOR_BASE_URL: https://${SUBDOMAIN}.${DOMAIN_NAME}/
WEBHOOK_URL: https://${SUBDOMAIN}.${DOMAIN_NAME}/
N8N_PROXY_HOPS: "1"
N8N_SECURE_COOKIE: "true"
# Optionally cap instance-wide concurrent production executions:
# N8N_CONCURRENCY_PRODUCTION_LIMIT: "15"
# Workers execute the queued workflows. They take the common environment
# unchanged from the anchor — that is the point: whatever the main does
# behaviourally, they do too. Scale by raising `replicas` (or run
# `docker compose up -d --scale n8n-worker=N`). Concurrency is per-worker.
n8n-worker:
<<: *n8n
command: worker --concurrency=5
deploy:
replicas: 2
# Volume names are pinned (`name:`) so they're stable regardless of the project
# directory — the backup/restore commands in DAY2.md reference these exact names.
volumes:
n8n_storage:
name: n8n_storage
db_storage:
name: db_storage
redis_storage:
name: redis_storage
caddy_data:
name: caddy_data
caddy_config:
name: caddy_config
networks:
n8n_net:
driver: bridge
assets/docker-compose.single.yml
# n8n — SINGLE / regular mode (one main process, SQLite, Caddy auto-TLS).
# Good for a single user / light-to-moderate load. For horizontal scale or
# heavy execution volume, use docker-compose.queue.yml instead.
#
# Secrets come ONLY from the .env file in this directory — never inline them here.
# n8n is NOT published on the host; Caddy reaches it over the private network.
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
# Consumed by the Caddyfile via {$VAR} so no domain is hard-coded.
- N8N_SUBDOMAIN=${SUBDOMAIN}
- N8N_DOMAIN=${DOMAIN_NAME}
- SSL_EMAIL=${SSL_EMAIL}
volumes:
- caddy_data:/data
- caddy_config:/config
- ${DATA_FOLDER}/caddy_config/Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- n8n
networks:
- n8n_net
n8n:
image: docker.n8n.io/n8nio/n8n:${N8N_IMAGE_TAG:-stable}
restart: unless-stopped
# NOTE: intentionally NO `ports:` mapping — n8n stays on the private network
# and is only reachable through Caddy. Do not publish 5678 to the host.
environment:
# --- public URL / reverse proxy ---
- N8N_HOST=${SUBDOMAIN}.${DOMAIN_NAME}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_EDITOR_BASE_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
- WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
- N8N_PROXY_HOPS=1
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- TZ=${GENERIC_TIMEZONE}
- NODE_ENV=production
# --- the encryption key: set explicitly + BACK IT UP (see security.md) ---
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# --- secure defaults ---
- N8N_SECURE_COOKIE=true
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_PERSONALIZATION_ENABLED=false
- N8N_HIRING_BANNER_ENABLED=false
- N8N_BLOCK_ENV_ACCESS_IN_NODE=true
- N8N_RUNNERS_ENABLED=true
# Python Code nodes (and real code isolation) need task runners in EXTERNAL mode:
# an n8nio/runners sidecar on the same tag. The stock image has no Python 3. See TASK_RUNNERS.md.
- N8N_DEFAULT_BINARY_DATA_MODE=filesystem
# --- optional backend modules (opt-in; off unless listed) ---
# e.g. `agents` for the Agents feature. Uncomment to enable.
# - N8N_ENABLED_MODULES=agents
# --- keep the DB/disk from growing forever ---
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=336
- EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000
# --- optional: turn the public REST API off if you don't use it ---
# - N8N_PUBLIC_API_DISABLED=true
volumes:
- n8n_data:/home/node/.n8n
- ${DATA_FOLDER}/local_files:/files
networks:
- n8n_net
# Volume names are pinned (`name:`) so they're stable regardless of the project
# directory — the backup/restore commands in DAY2.md reference these exact names.
volumes:
n8n_data:
name: n8n_data
caddy_data:
name: caddy_data
caddy_config:
name: caddy_config
networks:
n8n_net:
driver: bridge
assets/init-data.sh
#!/bin/bash
# Postgres bootstrap for n8n queue mode (runs once, on first container start).
# Creates the non-root user n8n connects with, separate from the Postgres
# superuser. Values come from the environment — no secrets live in this file.
# This is the standard n8n queue-mode init script; mount it at
# /docker-entrypoint-initdb.d/init-data.sh (the queue compose does this).
set -e
if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';
GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};
GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};
EOSQL
else
echo "SETUP INFO: POSTGRES_NON_ROOT_USER / POSTGRES_NON_ROOT_PASSWORD not set; skipping non-root user creation."
fi
CREDENTIAL_OVERWRITES.md
# Managed OAuth: credential overwrites
On n8n Cloud, connecting Google or Slack is one click — Cloud owns the OAuth app, so users
never see a client secret. Self-hosted has no OAuth app of its own, so by default **every user
must paste a Client ID and Client Secret** into every OAuth credential they create.
That is the problem this feature solves. **Credential overwrites** let you register one OAuth
app per credential type at instance level. n8n then hides those fields in the UI and users get
the plain "Sign in with …" button. Each user still authorises their own account and gets their
own tokens — only the *app* identity is shared.
Docs: <https://docs.n8n.io/administer/manage-credentials/credential-overwrites>
Use this when the user says any of: "we don't want to hand the client secret to everyone",
"can we get the Sign in with Google button", "every new hire has to set up OAuth again",
"how does Cloud do one-click Google".
## Two ways to set it, and only one you should use
| | `CREDENTIALS_OVERWRITE_DATA` | Endpoint |
|---|---|---|
| How | JSON blob in an env var | one authenticated `POST` after boot |
| Secret lands in | compose/`.env`, container env, `docker inspect` | request body only |
| n8n's own docs | "isn't recommended" | "the recommended approach" |
| Use it | never on a shared box | always |
The docs are blunt about the env route: *"Environment variables aren't protected in n8n, so the
data can leak to users."* Take that literally and use the endpoint.
## Setup
Three vars. In queue mode put them in the shared `x-n8n-env` anchor (see `QUEUE_MODE.md` on
env parity), except that only the main actually serves the endpoint.
```yaml
- CREDENTIALS_OVERWRITE_ENDPOINT=${CREDS_OVERWRITE_PATH} # random, not "credentials"
- CREDENTIALS_OVERWRITE_ENDPOINT_AUTH_TOKEN=${CREDS_OVERWRITE_TOKEN}
- CREDENTIALS_OVERWRITE_PERSISTENCE=true
```
Generate both values on the box, into `.env` (600), like every other secret:
```bash
printf "\nCREDS_OVERWRITE_PATH=creds-overwrite-%s\nCREDS_OVERWRITE_TOKEN=%s\n" "$(openssl rand -hex 6)" "$(openssl rand -hex 32)" >> .env
```
Recreate n8n, then push the OAuth app once. The endpoint is mounted at the **root** of the
n8n host, not under `/rest`:
```bash
curl -sS -X POST "https://n8n.example.com/${CREDS_OVERWRITE_PATH}" -H "Authorization: Bearer ${CREDS_OVERWRITE_TOKEN}" -H "Content-Type: application/json" -d '{"googleOAuth2Api":{"clientId":"...","clientSecret":"..."}}'
```
`Content-Type: application/json` is enforced — anything else returns an error, not a silent
no-op. Success is HTTP 200 with `success: true`.
## The auth token is not optional
**Leave `CREDENTIALS_OVERWRITE_ENDPOINT_AUTH_TOKEN` empty and the endpoint stands open.** n8n
only installs the auth middleware when the token is set; without it, the route still exists and
accepts the **first** POST it receives from anyone, then latches (a one-shot
`presetCredentialsLoaded` flag). Whoever gets there first registers the OAuth app that your
users will consent to.
So: always set the token, and make the path unguessable. With the token set, the one-shot latch
is bypassed and you can re-POST to rotate the app later.
Reverse proxies pass arbitrary paths through to n8n, so nothing in Caddy needs changing — which
also means nothing in Caddy is protecting this route for you.
## Set it on the parent credential type, not the leaf
Overwrites resolve along the credential inheritance chain. `GoogleBigQueryOAuth2Api` declares
`extends: ['googleOAuth2Api']`, and n8n merges every ancestor's overwrite before the leaf's.
One entry on **`googleOAuth2Api`** therefore covers Sheets, Drive, Gmail, Calendar, Docs,
BigQuery, Chat, Cloud Storage and the rest in one go. Registering each leaf type separately
works but is pointless duplication.
Before assuming a family shares a parent, check that credential's `extends` — not every vendor's
credentials are structured this way, and a leaf with no parent only gets its own entry.
## It will not break existing credentials
The overwrite fills a field **only when the stored value is `null`, `undefined` or empty**.
Every credential already holding its own Client ID and Secret keeps working untouched. The
overwrite applies to newly created credentials, and to any field a user left blank.
Escape hatch for users who insist on their own OAuth app:
```
N8N_SKIP_CREDENTIAL_OVERWRITE=googleOAuth2Api,slackOAuth2Api
```
For a listed type, if the credential has any overwritten field set to a value of its own, n8n
skips the overwrite entirely for that credential and uses what the user entered.
`N8N_MANAGED_OAUTH_SHOW_SCOPES` (comma-separated types) makes the UI display the scopes being
requested — worth enabling if users need to see what they are consenting to.
## Persistence, restarts and workers
With `CREDENTIALS_OVERWRITE_PERSISTENCE=true`, a POST is stored in the `settings` table under
key `credentialsOverwrite`, **encrypted with `N8N_ENCRYPTION_KEY`** — so it is covered by your
normal DB backup, and it is unreadable without the key you already had to protect.
Two consequences worth knowing:
- **Workers pick it up live.** Saving broadcasts a `reload-overwrite-credentials` pubsub
command; every worker reloads from the DB immediately, no restart and no redeploy. The
`PERSISTENCE` flag on a worker only matters at **cold start**, when it decides whether the
worker reads the row on boot. Set it on workers anyway — otherwise a worker that restarts
quietly loses the overwrite and its executions start failing on credentials that work fine in
the editor.
- **Without persistence** the data is in-memory only. It disappears on restart and you must
POST again, which is exactly the kind of thing nobody remembers at 3am.
If you set both `CREDENTIALS_OVERWRITE_DATA` and persistence, the DB copy is loaded second and
wins. Don't run both; pick one source of truth.
## Google-specific prerequisites
The overwrite only supplies the app identity. The OAuth app itself still has to be correct:
- **Redirect URI** on the OAuth client must be exactly
`https://<your-n8n-host>/rest/oauth2-credential/callback`. Get `WEBHOOK_URL` /
`N8N_EDITOR_BASE_URL` wrong and n8n will generate a `localhost` callback that never matches.
- **Consent screen publishing status.** An *External* app left in *Testing* issues refresh
tokens that expire after 7 days. On a per-user client that is one person's annoyance; behind a
shared client it logs out the whole company at once, weekly. Publish the app, or use
*Internal* if the org is on Google Workspace.
- **Enable the APIs** you intend to use in that Google Cloud project. The overwrite cannot fix
a disabled API.
## Verify
```bash
docker compose exec -T postgres sh -lc 'psql -U $POSTGRES_USER -d $POSTGRES_DB -A -t -c "select key, length(value) from settings where key = '"'"'credentialsOverwrite'"'"'"'
```
One row. The value is ciphertext, so length is all you should see — never decrypt it to "check".
Then in the UI, create a new credential of that type: the Client ID and Client Secret fields
should be gone, leaving only the sign-in button. That is the real acceptance test.
## What NOT to do
- **Don't leave the auth token empty.** See above — that is a takeover, not a rough edge.
- **Don't use `CREDENTIALS_OVERWRITE_DATA` on a multi-user instance.** Anything in the
container env is one Code node or one `docker inspect` away from a user.
- **Don't paste the client secret into a shell one-liner on a shared box** — it lands in shell
history. Read it from `.env` or a file, or type it into a heredoc.
- **Don't expect this to fix a broken credential.** Overwrites set the *app*'s ID and secret.
A credential that fails because its refresh token was revoked, expired, or belonged to someone
who left the company needs re-authorisation and an ownership change — a different job.
- **Don't skip the worker flag in queue mode.** It only bites after a worker restart, which is
the worst time to discover it.
DAY2.md
# Day-2: update, back up, restore
Operating the instance after it's live. All commands run from `<DATA_FOLDER>` on the box.
## Update the n8n image
n8n ships breaking changes between majors — pin a version in `.env` (`N8N_IMAGE_TAG`) and bump
it deliberately rather than chasing `:latest`.
```bash
cd <DATA_FOLDER>
# (optional) bump N8N_IMAGE_TAG in .env to a specific version first
docker compose pull
docker compose up -d # recreates only changed services; volumes/data persist
docker compose exec n8n n8n --version
```
(The docs' own sequence inserts `docker compose down` between `pull` and `up -d`; skipping it as
above avoids the full-stack teardown and is equally safe for data — either works.)
- **Queue mode:** `pull` + `up -d` updates main and workers together — keep them on the **same
version** (a mixed-version cluster misbehaves).
- **External task runners:** the `n8nio/runners` sidecar(s) must move to exactly the same
version. Drive both images from `N8N_IMAGE_TAG` so one bump updates both. If you mounted a
custom `n8n-task-runners.json`, diff it against the new image's default first
(`docker run --rm --entrypoint cat n8nio/runners:<new-tag> /etc/n8n-task-runners.json`). A
stale copy can silently drop env vars that a newer launcher passes to the runners. See
`TASK_RUNNERS.md`.
- On restart, containers get `N8N_GRACEFUL_SHUTDOWN_TIMEOUT` (default 30 s) to finish in-flight
executions before being killed — raise it if long-running workflows keep getting cut off
mid-update. (The old `QUEUE_WORKER_TIMEOUT` is deprecated in favor of this.)
- **Rollback is only half-safe.** Pinning the previous `N8N_IMAGE_TAG` works *if no DB migration
ran* during the update. n8n auto-runs migrations on boot and they are forward-only — the docs
document no downgrade path — so once the new version has booted against the DB, a clean
rollback means old image tag **plus restoring the pre-update DB backup**. This is why the
backup below comes *before* the update, not after it breaks.
- Update deliberately but regularly (docs suggest at least monthly, so you never jump many
versions at once) and read the release notes for breaking changes before **every** update, not
just majors. For instances a business depends on, docs recommend trying the update on a
staging copy first. Official update guide:
<https://docs.n8n.io/deploy/host-n8n/keep-n8n-running/update-n8n>.
## Change a setting (env var)
The most common day-2 operation, and the one with the most ways to go subtly wrong.
```bash
cd <DATA_FOLDER>
# edit .env (values) or docker-compose.yml (which vars exist)
docker compose config -q # catch YAML/interpolation errors before restarting
docker compose up -d --no-deps n8n # recreate just this service
```
- **`--no-deps` keeps the blast radius small.** Without it Compose may also touch the services
this one depends on. Restart only what actually needs the new value.
- **Don't restart via a wrapper script that pulls.** A `run.sh`/`update.sh` that does
`docker compose pull && up -d` turns "apply a setting" into "apply a setting *and* upgrade
n8n" — you then debug two changes at once, and the upgrade may have run a forward-only DB
migration you didn't plan for. Use plain `up -d` for env changes; use the update recipe above
when you actually mean to update.
- **Take a copy of the compose file first** (`cp docker-compose.yml docker-compose.yml.bak-$(date +%F)`)
so reverting is one `cp` rather than a reconstruction from memory.
- **Queue mode: decide whether the var is behavioural.** If it changes how n8n behaves rather
than what public URL it serves, it belongs in the `x-n8n-env` anchor and the workers need
recreating too (`docker compose up -d --no-deps n8n n8n-worker`). Re-run the parity diff from
`QUEUE_MODE.md` afterwards — that check is what turns "I think I set it" into "it's set
everywhere it needs to be".
- **Never restart to apply a changed `N8N_ENCRYPTION_KEY`.** That doesn't re-key anything; it
makes every stored credential undecryptable. The key is set once, at first boot, forever.
## Back up — what actually matters
Two things, and they're only useful **together**:
1. **The `N8N_ENCRYPTION_KEY`** — it's in `.env` (and the `n8n_data`/`n8n_storage` volume at
`~/.n8n/config`). Store it off-box. A DB backup without this key is undecryptable.
2. **The data:**
- **Single (SQLite):** the `n8n_data` volume (holds the SQLite DB, the key, and filesystem
binary data).
- **Queue (Postgres):** a `pg_dump` of the database. With the template's
`N8N_DEFAULT_BINARY_DATA_MODE=database`, binary data is **inside Postgres**, so the dump
covers it — the `n8n_storage` tar below is belt-and-braces for `~/.n8n` itself (config,
including any auto-generated key).
### Single (SQLite) — snapshot the volume
```bash
docker run --rm \
-v n8n_data:/data -v "$PWD":/backup alpine \
tar czf /backup/n8n_data-$(date +%F).tar.gz -C /data .
# also copy .env (it holds the encryption key) somewhere safe & private
```
### Queue (Postgres) — dump the DB
```bash
# Single-quote the inner command so $POSTGRES_USER/$POSTGRES_DB expand INSIDE the
# postgres container (where they're set), not in your host shell (where they aren't).
docker compose exec -T postgres \
sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
| gzip > n8n-db-$(date +%F).sql.gz
# optional but cheap — the ~/.n8n volume (config; name matches the compose `name:`):
docker run --rm -v n8n_storage:/data -v "$PWD":/backup alpine \
tar czf /backup/n8n_storage-$(date +%F).tar.gz -C /data .
# and back up .env (encryption key) off-box
```
Schedule these (cron) and copy the artifacts off the machine. Test a restore at least once.
### Alternative: CLI export (portable, human-readable)
For workflow-level backups (e.g. before risky edits) the CLI has a purpose-built flag:
```bash
docker compose exec -u node n8n n8n export:workflow --backup --output=/home/node/.n8n/backup/
```
Credentials export encrypted by default (`n8n export:credentials --all`) and need the same
`N8N_ENCRYPTION_KEY` to re-import; `--decrypted` writes **plaintext secrets** — treat such a
file as a live credential dump. CLI reference:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/use-the-command-line>.
## Restore
The golden rule: **restore the data with the original `N8N_ENCRYPTION_KEY` in place**, or saved
credentials won't decrypt. So put the backed-up key into `.env` *before* bringing n8n up.
### Single (SQLite)
```bash
# fresh box: lay down the project + .env (with the ORIGINAL encryption key), do NOT start n8n yet
docker volume create n8n_data
docker run --rm -v n8n_data:/data -v "$PWD":/backup alpine \
sh -c 'cd /data && tar xzf /backup/n8n_data-YYYY-MM-DD.tar.gz'
docker compose up -d
```
### Queue (Postgres)
```bash
# bring up just the DB first so it's ready to receive the dump
docker compose up -d postgres
gunzip -c n8n-db-YYYY-MM-DD.sql.gz | \
docker compose exec -T postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
# restore the binary volume if you backed it up, then:
docker compose up -d
```
## Routine checks
```bash
docker compose ps # health
docker compose logs -f n8n # main logs
docker system df # disk used by images/volumes
df -h # host disk (watch execution data + binary growth)
```
If disk creeps up, tighten execution pruning (`EXECUTIONS_DATA_MAX_AGE` /
`EXECUTIONS_DATA_PRUNE_MAX_COUNT`; defaults 336 h / 10 000 — the templates set them explicitly).
In single mode also confirm `N8N_DEFAULT_BINARY_DATA_MODE=filesystem` so run payloads aren't
bloating SQLite; in queue mode binary data intentionally lives in Postgres, so pruning is what
bounds it.
Going further (all optional):
- **Log tuning** — `N8N_LOG_LEVEL` (`info` default; `debug` when diagnosing) and
`N8N_LOG_OUTPUT=console` fit Docker; file output + rotation vars exist:
<https://docs.n8n.io/deploy/host-n8n/keep-n8n-running/set-up-logging>.
- **Prometheus** — `N8N_METRICS=true` exposes `/metrics` **unauthenticated**; since Caddy proxies
everything, that would be public — keep it off, or block `/metrics` in the Caddyfile first:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/enable-prometheus-metrics>.
- **Health/monitoring endpoints** — `/healthz` (process up) vs `/healthz/readiness` (DB ready):
<https://docs.n8n.io/deploy/host-n8n/keep-n8n-running/monitor-n8n>.
- **Periodic security audit** — `docker compose exec -u node n8n n8n audit` (or the
`n8n_audit_instance` MCP tool from the n8n-mcp pack):
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/run-security-audits>.
QUEUE_MODE.md
# Queue mode
Executions are pulled off a Redis queue by a pool of **worker** processes, so work runs in
parallel and scales horizontally. Template: `assets/docker-compose.queue.yml`.
## Architecture
| Service | Role |
|---|---|
| `caddy` | public reverse proxy, HTTPS |
| `n8n` (main) | editor UI, REST API, triggers/timers, **receives webhooks and enqueues** executions — it does not run them |
| `n8n-worker` | pulls jobs off the queue and **executes** workflows; scale the replica count for more throughput |
| `redis` | the Bull message queue holding pending executions |
| `postgres` | the shared database (workflows, credentials ciphertext, execution data) — **required** |
The docs mark SQLite as not recommended for queue mode; this skill treats **Postgres as
mandatory** — don't deploy queue mode without it. `init-data.sh` creates the non-root DB user
n8n connects as, separate from the Postgres superuser. Official guide:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/enable-queue-mode>.
## The settings that make it queue mode
Set on **main and every worker** (the `x-n8n` anchor applies them to both):
- `EXECUTIONS_MODE=queue`
- `QUEUE_BULL_REDIS_HOST=redis`, `QUEUE_BULL_REDIS_PORT=6379` (Redis auth/TLS/cluster vars
exist for external Redis; `QUEUE_BULL_REDIS_USERNAME` needs Redis ≥ 6 — full list:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/queue-mode>)
- `QUEUE_HEALTH_CHECK_ACTIVE=true` (workers expose `/healthz` for probes — off by default)
- `DB_TYPE=postgresdb` + the `DB_POSTGRESDB_*` connection vars
- **`N8N_ENCRYPTION_KEY` — identical everywhere.** Workers decrypt credentials to run nodes;
a mismatched key means workers can't decrypt and executions fail. The anchor sets it once
from `.env`; never override it per-service.
- `OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true` — even "Test workflow" runs go to workers.
The **main** additionally gets the public-URL vars (`N8N_HOST`, `WEBHOOK_URL`,
`N8N_EDITOR_BASE_URL`, `N8N_PROTOCOL=https`, `N8N_PROXY_HOPS=1`, `N8N_SECURE_COOKIE=true`) —
workers don't serve the UI so they don't need them.
## Env parity: the rule for every var you add later
Those public-URL vars are the *only* legitimate difference between the main and a worker.
Everything else is behavioural — which database, which queue, which encryption key, which
optional modules, how binary data is stored — and a worker that disagrees with the main about
behaviour is a worker that executes your workflows wrongly.
That is why the template declares the common environment once, in the `x-n8n-env` anchor, and
has the main merge it (`<<: *n8n-env`) before adding its public-URL vars. **Add new behavioural
vars to the anchor, not to the main's own `environment:` block.**
This failure mode is nastier than it sounds, because the main is what you look at. The editor
UI, the REST API and every node's dropdown are served by the main, so a feature enabled only
there *looks* configured — it's the execution, on a worker, that fails. And with
`OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true` even a manual "Test workflow" run lands on a
worker, so there is no local sanity check that would catch it.
Check parity whenever you change the environment:
```bash
diff <(docker compose exec -T n8n env | sort) \
<(docker compose exec -T --index 1 n8n-worker env | sort)
```
Only the public-URL/proxy vars above (plus per-container noise like `HOSTNAME`) should differ.
Anything else in that diff is a bug you just introduced.
## Optional modules
Some n8n features ship as backend **modules** that are off unless you list them in
`N8N_ENABLED_MODULES` (comma-separated). The Agents feature is the current example:
`N8N_ENABLED_MODULES=agents`. The template carries it commented out in the `x-n8n-env` anchor —
uncomment it there and it reaches main and workers together.
Enabling a module on the main only produces a characteristic failure: the module's REST routes
answer (so the UI populates), but the workers never registered the module's database entities,
so any node that touches it dies at runtime with a TypeORM error like
`EntityMetadataNotFoundError: No metadata for "Agent" was found`. If you see an error of that
shape after enabling a feature, check env parity first — it is almost always this.
**On Agents specifically, be straight with the user.** The docs say *"Queue mode isn't supported
for agents yet, and connecting channels (such as Telegram) can fail"* and recommend regular mode.
That sentence is broader than what the code actually gates: the leader/multi-main checks apply to
agent *schedules and chat channels*, while the `Message an Agent` node just runs the agent inline
in whichever process executes the workflow — so it does work on a worker once the module is
enabled there. Treat that as unsupported territory rather than a supported configuration: it can
break on any upgrade, and the agent knowledge base additionally needs the sandbox vars
(`N8N_AGENTS_AI_SANDBOX_*`, `DAYTONA_*`) on the workers too. If the user's main use of n8n is
agents, single/regular mode is the honest recommendation.
## Instance-wide OAuth apps (credential overwrites)
If you register a shared OAuth app with `CREDENTIALS_OVERWRITE_*`, only the **main** serves the
registration endpoint, but `CREDENTIALS_OVERWRITE_PERSISTENCE=true` belongs on the workers too:
a save is broadcast over pubsub so running workers reload immediately, yet a worker that
**cold-starts** without the flag never reads the stored row and silently loses the overwrite.
That is the classic parity failure from the section above — fine in the editor, broken in
execution. See `CREDENTIAL_OVERWRITES.md`.
## Scaling the workers
- Each worker runs `worker --concurrency=5` (5 simultaneous executions per worker; the flag's
upstream default is 10 — the template pins 5 as a safer floor for modest boxes; raise for
many light executions, lower for heavy ones).
- More throughput = more workers. The template sets `deploy.replicas: 2`, which **Docker Compose
v2 honors under `docker compose up`** (here it is *not* Swarm-only). To change the count, either
edit `deploy.replicas` and re-run `docker compose up -d`, or override at launch with
`docker compose up -d --scale n8n-worker=N` — a `--scale` value supersedes `replicas` (passing
both at once just prints a harmless conflict warning).
- **With external task runners (required for Python Code nodes) replicas stop working.** Each
worker needs its own `n8nio/runners` sidecar pointed at *that* worker's broker. A single runner
service behind the `n8n-worker` DNS name serves one random replica, and Code nodes on the other
workers time out. Define explicit `n8n-worker-N` + `task-runners-N` pairs and scale by adding
pairs. See `TASK_RUNNERS.md`.
- Rough capacity ≈ `replicas × concurrency` simultaneous executions, bounded by CPU/RAM and
`DB_POSTGRESDB_POOL_SIZE` (default 2 per process — raise it if many workers exhaust the pool).
- Optionally cap load with `N8N_CONCURRENCY_PRODUCTION_LIMIT` (default `-1` = off). Caveats:
it counts only **production** executions (webhook/trigger) — manual, sub-workflow, error, and
CLI runs bypass it — and in queue mode a value other than `-1` **supersedes the worker
`--concurrency` flag**. Details:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/control-concurrency>.
## Binary data: database or external storage — NOT filesystem
- **Queue mode does not support `filesystem` binary mode** (the docs are explicit), even with a
shared volume — workers wouldn't reliably resolve each other's files. The template sets
`N8N_DEFAULT_BINARY_DATA_MODE=database`: binary data lives in Postgres, visible to main and
every worker. Doc: <https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/handle-binary-data>.
- `database` mode makes execution **pruning** load-bearing — big payloads now grow Postgres, so
the template sets `EXECUTIONS_DATA_PRUNE=true` + age/count caps explicitly (binary data is
pruned as part of execution-data pruning).
- **External storage (S3 / Azure Blob) is Enterprise-licensed** — n8n won't even start in `s3`
mode without a valid license. If licensed: `N8N_DEFAULT_BINARY_DATA_MODE=s3` +
`N8N_EXTERNAL_STORAGE_S3_*` vars, and an S3 **lifecycle policy is mandatory** (in s3 mode n8n
delegates binary pruning to the bucket instead of doing it itself). Doc:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/use-external-storage>.
## Optional: dedicated webhook processors
For very webhook-heavy instances you can run `n8n webhook` processes (same env as a worker) and
route `/webhook/*` + `/webhook-waiting/*` to them at the proxy, keeping the main process
responsive. Set `N8N_DISABLE_PRODUCTION_MAIN_PROCESS=true` on the main so it stays out of the
webhook pool. Most deployments don't need this — add it only when webhook intake is the
bottleneck. Doc: <https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/enable-queue-mode>.
## Not in this template: multi-main (HA)
Running **multiple main processes** with leader election/failover exists but is
**Enterprise-only** (`N8N_MULTI_MAIN_SETUP_ENABLED=true` on every main). On the community
edition the main process stays a singleton; scale workers instead. If a user asks for "HA n8n"
without an Enterprise license, that's the honest answer.
## Memory
Queue mode wants more RAM than single: a practical floor is ~4 GB, with each worker wanting
~1–2 GB depending on workload — rules of thumb from real deployments; the docs don't publish
sizing tables. For OOM crashes the docs' first advice is to redesign the workflow (batches,
sub-workflows, less Code node), then raise the heap via `NODE_OPTIONS=--max-old-space-size`:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/fix-memory-issues>. Confirm the box
is sized before deploying.
## Verify
```bash
docker compose ps # postgres & redis healthy, then n8n (main) + workers Up
docker compose logs caddy | grep -i 'certificate obtained'
curl -fsS --retry 5 --retry-delay 10 https://<fqdn>/healthz
docker compose logs n8n-worker | grep -iE 'ready|listening|jobs' # worker is up + listening
# main and workers must agree on everything except the public-URL vars
diff <(docker compose exec -T n8n env | sort) <(docker compose exec -T --index 1 n8n-worker env | sort)
```
A real test: run a workflow from the editor and confirm a worker logs that it executed it.
README.md
# n8n Self-Hosting Skill
Expert guidance for a coding agent to deploy a **production self-hosted n8n** end-to-end onto a
fresh Linux VM — Docker Compose behind a **Caddy** reverse proxy with automatic HTTPS — in
either **single/regular mode** or **queue mode** (main + Redis + Postgres + workers), plus the
essential Day-2 operations (update, back up, restore).
This is the one **deployment/ops** skill in the pack. The other skills are about *building*
workflows through the n8n-mcp MCP server; this one is about *standing up the server* they run on.
It does not touch the workflow-building router or hooks — it triggers on its own description when
someone wants to self-host n8n.
---
## What it does
Takes a bare Ubuntu/Debian box with SSH access to a running, TLS-secured n8n:
1. **Asks single vs queue first** (the architectures differ).
2. Collects inputs — domain, TLS email, timezone, SSH target.
3. Preflights DNS + ports (the #1 cause of "it won't get a cert").
4. Installs Docker, lays down the project, **generates fresh secrets on the box**.
5. Brings the stack up behind Caddy and verifies the cert + reachability.
6. Hands off with update/backup/restore guidance.
It is opinionated toward **secure defaults that exceed a naive install**: the encryption key is
set explicitly (not auto-generated), internal ports (5678/5432/6379) are never published,
telemetry is off, Code-node `process.env` access is blocked, and execution data is pruned.
---
## Two modes
| | Single / regular | Queue |
|---|---|---|
| Processes | one n8n | main + N workers |
| Services | n8n + Caddy (SQLite) | n8n + workers + Redis + Postgres + Caddy |
| Executes | in the main process | on workers, in parallel |
| For | one user, light/moderate load | high volume, horizontal scale |
---
## Skill Activation
Activates when the user wants to:
- Self-host / install / deploy / provision n8n on their own server, VPS, or VM
- Set up n8n with Docker Compose, a reverse proxy, or SSL/HTTPS
- Run n8n in queue mode / with workers / scale n8n
- Update, back up, restore, or harden a self-hosted n8n
- Make Python Code nodes work on self-hosted n8n (task runners in external mode)
**Not** for n8n Cloud, and not for building workflows (that's the rest of the pack).
**Example queries**:
- "Deploy n8n to my fresh Hetzner box at n8n.mycompany.com, queue mode."
- "Install self-hosted n8n with Docker and HTTPS on this Ubuntu server."
- "Set up n8n with workers so it can handle a lot of executions."
- "How do I back up and update my self-hosted n8n?"
- "My Python Code node says 'Python runner unavailable: Python 3 is missing from this system'."
---
## File Structure
### SKILL.md
The end-to-end orchestrator: mode selection, secret-hygiene rules, inputs, the numbered deploy
flow, verification, and "what not to do."
### Reference files
- **SINGLE_MODE.md** — single-instance specifics, SQLite vs Postgres, when/how to graduate to queue.
- **QUEUE_MODE.md** — queue architecture, worker scaling/concurrency, the shared encryption key, binary data (`database` mode; Enterprise S3/Azure), webhook processors, multi-main licensing.
- **SECURITY.md** — secret generation, the encryption-key rules, and the full hardening checklist.
- **TASK_RUNNERS.md** — task runners in external mode: the `n8nio/runners` sidecar that Python Code nodes need, one sidecar per queue worker, module allowlists (`/etc/n8n-task-runners.json`), verification and failure signatures.
- **DAY2.md** — updating the image, backing up (key + volume + Postgres), and restoring.
### assets/
Secret-free templates the agent copies to the box:
- `docker-compose.single.yml`, `docker-compose.queue.yml`
- `Caddyfile` (domain-free; driven by `.env`)
- `.env.single.example`, `.env.queue.example` (placeholder values only)
- `init-data.sh` (Postgres non-root user bootstrap for queue mode)
---
## Security posture
Every shipped file is **secret-free and domain-free**. Real secrets only ever exist in a `.env`
on the target box (mode 600), referenced as `${VAR}`. The skill instructs the agent to generate
fresh secrets per box, never reuse an encryption key or `.env` across instances, redact values
when inspecting, and keep internal services off the public interface.
---
## Version
**Version**: 1.2.0 — task runners in external mode (`TASK_RUNNERS.md`), verified on a live n8n 2.38.5 box: the
`n8nio/runners` sidecar that Python Code nodes need, one sidecar per queue worker, Python/JS module
allowlists via the launcher config, and upgrading n8n and the runners together.
Previously 1.1.0 — realigned with the restructured official hosting docs
([docs.n8n.io/deploy/host-n8n](https://docs.n8n.io/deploy/host-n8n)): queue-mode binary data
moved to `database` mode (filesystem is unsupported in queue mode), SSRF protection, encryption-key
rotation, task-runner isolation modes, owner pre-provisioning, CLI backup/export, and monitoring
endpoints, with deep links to the docs for the uncommon paths.
**Compatibility**: Docker Engine + Compose v2 on a Debian/Ubuntu host; n8n official image
(`docker.n8n.io/n8nio/n8n`); Caddy 2 for automatic TLS.
---
**Remember**: pick the mode first, preflight DNS + ports, generate fresh secrets on the box, and
back up the encryption key off-box — a database without its key is undecryptable.
SECURITY.md
# Secrets & hardening
The deploy flow lives in `SKILL.md`; this file is the security depth it points to.
## Generating secrets (on the target box)
Generate each value **on the server** and **write it into `.env`, replacing the matching
`REPLACE_WITH_…` placeholder** — generating without substituting leaves the literal placeholder
as the password, and Postgres/n8n then fail to connect. Never reuse values across instances.
```bash
cd <DATA_FOLDER>
# Encryption key — encrypts all stored credentials. REQUIRED, both modes.
openssl rand -base64 32
# Queue mode also needs two Postgres passwords:
openssl rand -base64 24 # POSTGRES_PASSWORD (superuser)
openssl rand -base64 24 # POSTGRES_NON_ROOT_PASSWORD (the user n8n connects as)
```
Substitute each into `.env` (edit it directly, or for a fresh `.env` with no special chars in
the value, e.g.):
```bash
KEY=$(openssl rand -base64 32)
# write it in place of the placeholder (use a delimiter that won't clash with base64's / and +)
sed -i "s|^N8N_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${KEY}|" .env
# repeat for POSTGRES_PASSWORD / POSTGRES_NON_ROOT_PASSWORD in queue mode
```
Then **confirm nothing was missed** and lock the file down:
```bash
grep REPLACE_WITH_ .env # must print NOTHING before you launch
chmod 600 .env
```
## The encryption key (`N8N_ENCRYPTION_KEY`) — read this twice
- It encrypts every credential n8n stores. **Lose it or change it and all saved credentials
become undecryptable** — effectively a reset.
- **Set it explicitly before the first start.** If you start n8n once without it, n8n
auto-generates one into the `n8n_data` volume (`~/.n8n/config`); adding a *different* key
later breaks decryption. The templates set it from `.env`, so do step 4 before step 6.
- **Queue mode: the exact same key must reach the main process and every worker.** The
`x-n8n` anchor in the queue compose guarantees this — don't override it per-service.
- **Back it up off the box** (password manager / secrets vault). A database backup is useless
without the matching key. To recover the auto-generated one if you ever need it:
`docker compose exec n8n cat /home/node/.n8n/config`.
- **Never "rotate" the key by just editing `.env`** — that's the lose-it-or-change-it failure
above. Real rotation is a formal, one-way feature (UI: Settings → Data Encryption Keys, behind
the `N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION` flag; full DB backup required first; records
re-encrypt lazily): <https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/rotate-encryption-keys>.
## Hardening checklist
Most of these are already baked into the `assets/` templates (marked ✓). The rest are optional
toggles to apply based on the user's needs.
| Setting | Templates | Why |
|---|---|---|
| `N8N_ENCRYPTION_KEY` set explicitly | ✓ | Key lives in your secret store, not just a volume |
| `N8N_SECURE_COOKIE=true` + `N8N_PROXY_HOPS=1` + `N8N_PROTOCOL=https` | ✓ | Login cookie only over HTTPS; n8n trusts Caddy's `X-Forwarded-*`. (Get these wrong and secure-cookie can lock you out.) |
| `WEBHOOK_URL` / `N8N_EDITOR_BASE_URL` = the public HTTPS URL | ✓ | Otherwise n8n hands out `http://localhost:5678/...` webhook & OAuth-callback URLs |
| `N8N_DIAGNOSTICS_ENABLED=false` | ✓ | Turns off anonymous telemetry (side effect: also disables the Code node's "Ask AI"). For full outbound isolation (update pings, template fetches) see <https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/isolate-n8n> |
| `N8N_BLOCK_ENV_ACCESS_IN_NODE=true` | ✓ | Code-node/expressions can't read `process.env` (your container secrets). Relax only if a workflow genuinely needs an env var. |
| `N8N_DEFAULT_BINARY_DATA_MODE` set explicitly | ✓ | Default keeps binary data **in memory**. Single → `filesystem` (files to disk, not RAM/SQLite). Queue → `database` (filesystem is unsupported in queue mode; S3/Azure need Enterprise) — see `QUEUE_MODE.md` |
| Execution-data pruning (`EXECUTIONS_DATA_PRUNE` + `_MAX_AGE` + `_PRUNE_MAX_COUNT`) | ✓ | Caps disk/DB growth and how long run data (which can contain PII) is retained |
| Internal ports never published (5678/5432/6379) | ✓ | Only Caddy is internet-facing |
| `N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true` | optional | Forces `0600` on the settings file in `~/.n8n` (default `false`) |
| `N8N_RUNNERS_ENABLED=true` | ✓ | Moves Code-node execution into a task runner (no-op on n8n ≥ 2.0, where runners are always on). The default **internal** mode is a child process of n8n with the same user and access, which is not isolation. The docs reserve it for instances without sensitive data. Real isolation is an **external** `n8nio/runners` sidecar (`N8N_RUNNERS_MODE=external`), which is also the only way Python Code nodes run on the stock image. Setup, queue-mode pairing and verification: `TASK_RUNNERS.md`. Hardening: <https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/harden-task-runners> |
| Code-node module allowlists left at defaults | ✓ | JS: `NODE_FUNCTION_ALLOW_BUILTIN` / `NODE_FUNCTION_ALLOW_EXTERNAL`. Python: `N8N_RUNNERS_STDLIB_ALLOW` / `N8N_RUNNERS_EXTERNAL_ALLOW`, empty by default, so even `import json` is rejected. Allow specific modules only, never `*` on a multi-user box (Python `*` includes `os`/`subprocess`/`socket`). In internal mode they are env vars on n8n. **In external mode they are `env-overrides` in the sidecar's `/etc/n8n-task-runners.json`**, not container env vars. See `TASK_RUNNERS.md`: <https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/enable-modules-in-code-node> |
| `N8N_SSRF_PROTECTION_ENABLED=true` | optional | Off by default (newer n8n only). Stops HTTP-type nodes from reaching loopback/private/link-local ranges — i.e. Docker-network neighbors like `postgres:5432` and cloud metadata IPs. Enable on multi-user boxes; allowlist genuinely-needed internal hosts via `N8N_SSRF_ALLOWED_HOSTNAMES`/`_ALLOWED_IP_RANGES`: <https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/enable-ssrf-protection> |
| `NODES_EXCLUDE` | optional | Execute Command is already blocked by default (setting `NODES_EXCLUDE=[]` would *re-enable* it). To block more, JSON-array syntax: `NODES_EXCLUDE=["n8n-nodes-base.readWriteFile"]` — <https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/block-specific-nodes> |
| `N8N_PUBLIC_API_DISABLED=true` | optional | Turn the public REST API off if unused (commented in the single template); pair with `N8N_PUBLIC_API_SWAGGERUI_DISABLED=true` to also drop the API playground |
| OS firewall: only 22/80/443 | apply in step 5 | `ufw allow OpenSSH && ufw allow 80 && ufw allow 443 && ufw enable` |
| Owner account + 2FA | hand-off | Whoever completes the owner-setup form first claims the instance — do it before sharing the URL. Enable 2FA (per-user 2FA is free; org-wide *enforcement* via `N8N_MFA_ENFORCED_ENABLED` needs a paid plan). Automated deploys can pre-provision the owner by env (n8n ≥ 2.17: `N8N_INSTANCE_OWNER_MANAGED_BY_ENV` + email + bcrypt password hash): <https://docs.n8n.io/deploy/host-n8n/configure-n8n/user-management> |
| SMTP (`N8N_EMAIL_MODE=smtp` + `N8N_SMTP_*`) | multi-user | Without SMTP, invite links must be copied manually and users **cannot self-serve password resets**: <https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/user-management-and-2fa> |
| `CREDENTIALS_OVERWRITE_ENDPOINT_AUTH_TOKEN` set (if using overwrites) | `CREDENTIAL_OVERWRITES.md` | Only if you enable `CREDENTIALS_OVERWRITE_ENDPOINT`. With an empty token n8n installs no auth middleware and the route accepts the **first** POST from anyone, then latches — whoever gets there first owns the OAuth app your users consent to. |
| Keep host + image updated | `DAY2.md` | Patch the OS; update the n8n image deliberately |
## Don't-leak rules (client safety)
- **Fresh secrets per box** — never carry an encryption key, DB password, or `.env` from one
client's instance to another.
- **No secrets in committed files** — `.env` only (600), referenced as `${VAR}`. Scan any file
you're about to commit: it must contain zero real keys, tokens, passwords, or client domains.
- **Redact when inspecting** — when reading a `.env` to debug, mask values
(`sed -E 's/=.*/=<redacted>/'`); never paste raw secret values into chat or logs.
- **The Caddyfile and compose templates are domain-free** (the domain comes from `.env`), so
they're safe to keep in version control; a `.env` is not.
SINGLE_MODE.md
# Single / regular mode
One n8n process handles everything: the editor UI, the REST API, triggers/timers, and it
**executes workflows in-process**. Simplest to run and reason about. Template:
`assets/docker-compose.single.yml`.
## What you get
- `caddy` — public reverse proxy, automatic HTTPS (80/443).
- `n8n` — the single process; data in the `n8n_data` volume (`/home/node/.n8n`).
- **SQLite** by default (the DB file lives in `n8n_data`). No separate database container.
## Is single mode the right call?
Good fit: one user or a small team, light-to-moderate execution volume, you value simple ops
and simple backups. The whole instance is one volume to back up.
Outgrow it when: executions queue up behind each other, long/heavy runs block the UI, or you
need to scale across CPU cores or machines. That's **queue mode** (`QUEUE_MODE.md`).
## SQLite vs Postgres in single mode
These are the only two supported databases — MySQL/MariaDB support no longer exists, and
Postgres is supported on "actively maintained versions" only:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/choose-n8ns-database>.
- **SQLite (default, template):** zero extra moving parts; back up by snapshotting the
`n8n_data` volume. Great for most single-instance installs.
- **Postgres (optional upgrade):** more robust under write pressure and the standard if you
expect to grow. If you know you'll move to queue mode soon, starting on Postgres now avoids a
later SQLite→Postgres migration. To use it, add a `postgres:16` service (see the queue
template for the service + `init-data.sh` + healthcheck) and set on n8n:
`DB_TYPE=postgresdb`, `DB_POSTGRESDB_HOST=postgres`, `DB_POSTGRESDB_DATABASE`,
`DB_POSTGRESDB_USER`, `DB_POSTGRESDB_PASSWORD`. Everything else stays the same.
## Migrating SQLite → Postgres later
There's no in-place switch. The supported path is: export workflows & credentials, stand up
Postgres, point n8n at it (fresh DB), and re-import. The CLI runs **inside the container as the
`node` user**:
```bash
docker compose exec -u node n8n n8n export:workflow --backup --output=/home/node/.n8n/backup/
docker compose exec -u node n8n n8n export:credentials --all --output=/home/node/.n8n/creds.json
# after pointing n8n at Postgres:
docker compose exec -u node n8n n8n import:workflow --separate --input=/home/node/.n8n/backup/
docker compose exec -u node n8n n8n import:credentials --input=/home/node/.n8n/creds.json
```
Credentials export **encrypted by default** — they only re-import under the **same
`N8N_ENCRYPTION_KEY`**, so keep the key unchanged across the migration. (`--decrypted` exists
but writes plaintext secrets to disk — avoid it unless that's explicitly wanted, and shred the
file after.) Plan a short maintenance window. CLI reference:
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/use-the-command-line>.
## Resource notes
A single instance runs comfortably on a small box (≈1–2 GB RAM for light use). Heavy Code-node
or binary work wants more headroom. Set `N8N_DEFAULT_BINARY_DATA_MODE=filesystem` (template
default) so big files don't sit in memory/DB.
## Verify
```bash
docker compose ps # caddy + n8n Up
docker compose exec n8n wget -qO- http://localhost:5678/healthz # n8n itself up (internal)
docker compose logs caddy | grep -i 'certificate obtained' # cert issued (first boot: ~1–2 min)
curl -fsS --retry 5 --retry-delay 10 https://<fqdn>/healthz # public; retry covers ACME delay
```
A first-boot TLS failure usually means the cert hasn't issued yet, not that n8n is down. Then open
`https://<fqdn>` and create the owner account immediately (first visitor becomes the owner).
SKILL.md
---
name: n8n-self-hosting
description: Deploy a production self-hosted n8n end-to-end to a fresh Linux VM over SSH, using Docker Compose behind a Caddy reverse proxy with automatic HTTPS. Use whenever the user wants to self-host, install, provision, or deploy n8n on their own server/VPS (Hetzner, DigitalOcean, AWS EC2, bare metal) — single/regular mode or queue mode with workers — or to update, back up, restore, or harden such an instance, or make Python Code nodes run on it (task runners). For SELF-HOSTED n8n (Docker), not n8n Cloud and not building workflows. The skill makes the agent ask single-vs-queue first, collect domain/SSH/timezone inputs, generate fresh secrets on the box, and bring the stack up with TLS. Trigger on "deploy n8n", "self-host n8n", "n8n docker compose", "n8n queue mode / workers", "n8n reverse proxy / SSL", "back up / update my n8n", "Python runner unavailable" / "n8nio/runners sidecar", or "we don't want to give every user the OAuth client secret" / "enable Sign in with Google" (credential overwrites).
---
# Deploying self-hosted n8n
This skill takes a **fresh Linux VM** (Ubuntu/Debian, root or sudo SSH) to a **running,
HTTPS, production n8n** via Docker Compose behind **Caddy** (automatic Let's Encrypt TLS).
It is for **self-hosted n8n on Docker** — not n8n Cloud, and not for building workflows
(that's the rest of this pack).
Two deployment modes. The architectures differ, so **pick the mode before doing anything**.
You drive this end-to-end over SSH: preflight → install Docker → lay down the project →
generate secrets → launch → verify TLS → hand off. The template files live in `assets/`;
the per-mode and security depth live in the reference files named below.
## Rule 0 — choose the mode (ask the user)
Do not guess. Ask, then commit to one:
| | **Single / regular** | **Queue** |
|---|---|---|
| Processes | one n8n | main + N workers |
| Extra services | none (SQLite) | Redis (queue) + Postgres (DB) |
| Executes workflows | in the main process | on workers, in parallel |
| Good for | 1 user, light/moderate load, simplest ops | high volume, heavy/long executions, horizontal scale |
| Compose | `assets/docker-compose.single.yml` | `assets/docker-compose.queue.yml` |
| Deep dive | **`SINGLE_MODE.md`** | **`QUEUE_MODE.md`** |
If unsure, start **single** — it's the simplest correct thing and covers most needs. Moving
to queue later means swapping the compose file and migrating SQLite→Postgres, so if the user
already expects real volume, start **queue**.
## Rule 1 — secret hygiene (non-negotiable)
A misstep here leaks client credentials. Be diligent:
1. **Generate every secret fresh, on the target box.** Never copy an encryption key, DB
password, or `.env` from another n8n instance into this one. See `SECURITY.md` for the
`openssl` commands.
2. **Secrets live only in `.env`** (mode 600), referenced by the compose as `${VAR}`. Never
inline a secret into `docker-compose.yml`, the Caddyfile, or anything you commit.
3. **The `N8N_ENCRYPTION_KEY` is sacred.** It encrypts every stored credential. If it's lost
or changes, all saved credentials become undecryptable. Set it explicitly, and tell the
user to back it up **off the box**. Don't echo it into long-lived logs or chat history
beyond what's needed to hand it over.
4. **Never expose internal services.** Only Caddy (80/443) is public. n8n (5678), Postgres
(5432), Redis (6379) stay on the private Docker network — the templates already omit their
host port mappings. Don't add them.
5. **`.env` and Caddy's `caddy_data` volume (the issued certs + ACME account key) are not
artifacts to share.** If you're working inside a git repo, confirm `.env` is git-ignored
before any commit.
## Inputs to collect up front
- **SSH target** — `user@host` and how you authenticate (key path or the user confirms the agent already has access). Root or a sudo user.
- **Domain** — the full hostname n8n will live at, e.g. `n8n.example.com` (→ `SUBDOMAIN=n8n`, `DOMAIN_NAME=example.com`). The user must control its DNS.
- **TLS email** — for Let's Encrypt (`SSL_EMAIL`).
- **Timezone** — IANA name for Schedule/Cron nodes (e.g. `Europe/Warsaw`), else `Etc/UTC`.
- **Mode** — single or queue (Rule 0). Queue → confirm the box has enough RAM (rough floor ~4 GB; each worker wants ~1–2 GB).
- **Optional modules** — some features (currently **Agents**) are backend modules that stay off
unless listed in `N8N_ENABLED_MODULES`. Ask only if the user brings one up; if they do, read
the modules section of `QUEUE_MODE.md` before enabling it, because in queue mode it has to
reach the workers as well.
- **Python Code nodes / who edits workflows** — ask *"Will workflows use Python Code nodes, or
will anyone besides you edit workflows?"* A yes to either means task runners in **external
mode**: a `n8nio/runners` sidecar (one per worker in queue mode). The stock `n8nio/n8n` image has
no Python 3, so in the default internal mode every Python Code node fails with `Python runner
unavailable: Python 3 is missing from this system`. Read `TASK_RUNNERS.md` before step 3.
## The deploy flow
Work through these in order. `SINGLE_MODE.md` / `QUEUE_MODE.md` give the mode-specific command
detail; `SECURITY.md` covers secret generation and hardening; `DAY2.md` covers update/backup/restore.
### 1. Preflight (the cheapest failure is the one you catch here)
- SSH in; confirm the OS is Debian/Ubuntu-like (`. /etc/os-release`).
- **DNS must already point at the box.** Compare the box's public IP (`curl -s ifconfig.me`)
with `dig +short <fqdn>` (run it from the box AND ideally your laptop). If they don't match,
**stop** — Caddy's ACME challenge will fail. Have the user create the A record, wait for it
to propagate, then continue.
- Ports **80 and 443** must be reachable from the internet. Check the host firewall AND any
cloud security group / network firewall (Hetzner Cloud, AWS SG, etc.) — these are outside
the box and a common silent blocker.
### 2. Install Docker (if absent)
- Check `docker --version` and `docker compose version`. If missing, install Docker Engine +
the Compose plugin (Docker's official `get.docker.com` script on Ubuntu/Debian is fine).
Re-check `docker compose version` before proceeding.
### 3. Lay down the project
- Pick `DATA_FOLDER` — an **absolute path**, e.g. `/opt/n8n`. The `DATA_FOLDER` value in `.env`
**must equal this exact directory** (the compose mounts `${DATA_FOLDER}/caddy_config/Caddyfile`,
and `init-data.sh` is mounted via a relative `./` path), so always run `docker compose` from
here. Create it, plus `caddy_config/` and `local_files/` inside.
- **Get the template files onto the box.** They live in this skill's `assets/` on *your* machine,
not on the server — transfer each one. Either `scp` them up, or (no local copy needed) write
each file's contents over SSH, e.g.
`ssh <target> 'cat > <DATA_FOLDER>/docker-compose.yml' < assets/docker-compose.single.yml`.
Land them with these exact names:
- the chosen compose → `<DATA_FOLDER>/docker-compose.yml` (rename it to exactly this)
- `Caddyfile` → `<DATA_FOLDER>/caddy_config/Caddyfile`
- **queue only:** `init-data.sh` → `<DATA_FOLDER>/init-data.sh`, then `chmod +x` it
- the matching `.env.*.example` → `<DATA_FOLDER>/.env`
- **External task runners (Python):** add the runner env vars and the `task-runners` sidecar to
the compose now (`TASK_RUNNERS.md` has the snippet for each mode). Generate
`N8N_RUNNERS_AUTH_TOKEN` in step 4 with the other secrets.
### 4. Fill `.env` + generate secrets
- Set `DATA_FOLDER`, `DOMAIN_NAME`, `SUBDOMAIN`, `SSL_EMAIL`, `GENERIC_TIMEZONE`.
- Generate each secret **on the box** with `openssl` (`SECURITY.md` has the commands) and **write
it into `.env`, replacing the matching `REPLACE_WITH_…` placeholder**: `N8N_ENCRYPTION_KEY`;
queue also `POSTGRES_PASSWORD` + `POSTGRES_NON_ROOT_PASSWORD`.
- **Before launching, confirm none are left unset:** `grep REPLACE_WITH_ .env` must return nothing
— a leftover placeholder becomes the literal password and Postgres/n8n fail to connect.
- `chmod 600 .env`. Record the encryption key so the user can back it up off-box.
### 5. Firewall
- `ufw`: allow OpenSSH + 80 + 443, then enable. Do **not** open 5678/5432/6379.
### 6. Launch
- `cd <DATA_FOLDER> && docker compose up -d`.
- Queue mode brings up Redis + Postgres + main + workers (workers via `replicas`). To add
capacity: `docker compose up -d --scale n8n-worker=N`.
### 7. Verify (don't declare success without this)
- `docker compose ps` — every service `Up`/healthy (queue: postgres & redis `healthy` first).
- **n8n itself up (internal):** `docker compose exec n8n wget -qO- http://localhost:5678/healthz`
→ `{"status":"ok"}`. This separates "n8n is running" from "TLS isn't ready yet."
- **Cert issued:** `docker compose logs caddy | grep -i 'certificate obtained'`. First-boot ACME
can take a minute or two; until it finishes, a public `https://` request fails TLS — that means
the cert is still pending, **not** that n8n is down.
- **Public reachability (with retry):** `curl -fsS --retry 5 --retry-delay 10 https://<fqdn>/healthz`
→ `{"status":"ok"}`. (`/healthz` only proves the process is reachable; `/healthz/readiness`
additionally confirms the DB is connected and migrated — use it when debugging a boot loop.)
- **Queue mode — main and workers must agree.** Diff their environments:
`diff <(docker compose exec -T n8n env | sort) <(docker compose exec -T --index 1 n8n-worker env | sort)`.
Only the public-URL/proxy vars should differ. Anything else means a behavioural setting reached
the main but not the workers — and workers are what execute workflows, so it fails at runtime
in one node rather than at boot. `QUEUE_MODE.md` explains the rule.
- **External task runners:** `docker compose logs n8n | grep 'Registered runner'` must show both
`launcher-javascript` and `launcher-python` (queue: check every worker). Then smoke-test a
Python Code node (`TASK_RUNNERS.md` → Verify).
- Open `https://<fqdn>` → the **owner setup** screen. **Whoever completes that signup form first
claims the instance** — an exposed un-owned instance is a race, so create the owner account
immediately, before sharing the URL. Enable 2FA. (Automated deploys can pre-provision the
owner via env vars instead — see the owner row in `SECURITY.md`.)
### 8. Hand off
- Give the user: the URL, where the project lives, the encryption key to store safely, and the
Day-2 basics (update / backup / restore) from **`DAY2.md`**.
## What NOT to do
- **Don't skip the DNS/ports preflight.** A wrong A record or a closed cloud firewall is the
#1 reason Caddy can't get a cert and n8n looks "broken."
- **Don't publish 5678/5432/6379** to the host. Caddy reaches n8n over the private network.
- **Don't reuse another instance's encryption key or `.env`.** Fresh secrets per box.
- **Don't run queue mode on SQLite.** Queue requires Postgres (the template already wires it).
- **Don't put secrets in `docker-compose.yml` or the Caddyfile.** `.env` only.
- **Don't add a behavioural env var to the main only (queue mode).** Modules, DB, queue,
binary-data and encryption settings belong in the shared `x-n8n-env` anchor so workers get
them too; only the public-URL/proxy vars are main-only. See `QUEUE_MODE.md`.
- **Don't use `:latest` blindly.** Pin `N8N_IMAGE_TAG`; update deliberately (`DAY2.md`).
- **Don't promise Python Code nodes on the stock image in internal mode.** They need the
`n8nio/runners` sidecar, on exactly the n8n version, with imports allowlisted in the launcher
config. By default even `import json` is rejected. See `TASK_RUNNERS.md`.
- **Don't `--scale` queue workers once runners are external.** A sidecar serves exactly one
worker. Add worker + runner pairs instead (`TASK_RUNNERS.md`).
## Reference files
- **`SINGLE_MODE.md`** — single-instance specifics, SQLite vs Postgres, when to graduate to queue.
- **`QUEUE_MODE.md`** — queue architecture, workers/concurrency/scaling, shared encryption key, the main-vs-worker **env-parity rule**, optional backend modules (`N8N_ENABLED_MODULES`, e.g. Agents), binary data (`database` mode — filesystem is unsupported in queue mode; S3/Azure = Enterprise), webhook processors, multi-main licensing.
- **`SECURITY.md`** — generating secrets, the encryption-key rules, the full hardening checklist (telemetry off, env-access block, public API, firewall, secure cookies).
- **`CREDENTIAL_OVERWRITES.md`** — managed OAuth: register one OAuth app instance-wide so users
never see a client ID/secret ("Sign in with Google" on self-hosted). The endpoint-vs-env choice,
the **mandatory** endpoint auth token, parent-type inheritance, persistence and worker reload.
- **`TASK_RUNNERS.md`** — task runners in external mode: why Python Code nodes need the `n8nio/runners` sidecar, the compose snippet for single mode and the one-sidecar-per-worker pattern for queue mode, allowlisting Python/JS modules via `/etc/n8n-task-runners.json`, the builtin deny list, verification, failure signatures, and upgrading n8n and the runners together.
- **`DAY2.md`** — changing a setting (env var) safely, updating the image, backing up (encryption key + volume + Postgres), and restoring.
- **`assets/`** — the templates: `docker-compose.single.yml`, `docker-compose.queue.yml`, `Caddyfile`, `.env.single.example`, `.env.queue.example`, `init-data.sh`.
Authoritative upstream reference: the official hosting docs live at
<https://docs.n8n.io/deploy/host-n8n> (restructured mid-2026 from the old `/hosting/` paths —
prefer these URLs). The env-var reference index is at
<https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables>.
When this skill and the live docs disagree, trust the docs and tell the user.
TASK_RUNNERS.md
# Task runners & Python Code nodes
Code nodes (JavaScript and Python) execute inside **task runners**, not in the n8n process. This
file covers switching a Docker deployment to **external mode** — a `n8nio/runners` sidecar next to
n8n — which is what makes Python Code nodes work and what the docs require for real isolation.
Official guide: <https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-task-runners>.
## When you need it
- **Any Python Code node.** The official `n8nio/n8n` image ships no Python 3. In the default
**internal** mode n8n launches the runners as its own child processes, so the Python runner has
nothing to start and every Python Code node fails with:
`Python runner unavailable: Python 3 is missing from this system`.
JavaScript keeps working in internal mode (Node is in the image), which is why this surprises
people. There is no env var that fixes it inside the n8n container — add the sidecar.
- **Hardening.** Internal mode runs user code as the same user, on the same host, as n8n: code
that escapes the sandbox can read the database, the encryption key and stored credentials. The
docs call external mode the production setup for any instance with sensitive data or more than
one person editing workflows.
Ask the user during input collection: *"Will workflows use Python Code nodes, or will anyone
besides you edit workflows?"* A yes to either means external mode.
## How the pieces fit
- **n8n is the task broker.** It listens on port `5679`, inside the Docker network only — never
publish it.
- **The sidecar (`n8nio/runners`)** holds a launcher plus the JavaScript runner and the Python
runner (Python 3.13 on n8n 2.38). The launcher connects to the broker, then starts runner
processes on demand and stops them after `N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT` (default 15 s) idle.
- **The runner version must equal the n8n version.** Drive both images from the same tag
variable. The image lives on **Docker Hub** (`n8nio/runners`) — there is no
`docker.n8n.io/n8nio/runners`.
- **External mode moves every Code node, JavaScript included,** into the sidecar. JS module
allowlists move with it (see "Allowlisting modules").
- **The launcher config** is `/etc/n8n-task-runners.json` inside the sidecar. It decides which env
vars reach each runner (`allowed-env`) and pins some values (`env-overrides`).
## Single mode: what to add
`.env` — generate the token on the box, like every other secret (`SECURITY.md`):
```bash
echo "N8N_RUNNERS_AUTH_TOKEN=$(openssl rand -hex 32)" >> .env
# N8N_IMAGE_TAG must be a real version (e.g. 2.38.5) or `stable` — the same value feeds both images
```
`docker-compose.yml` — three vars on `n8n`, plus a new service:
```yaml
n8n:
image: docker.n8n.io/n8nio/n8n:${N8N_IMAGE_TAG:-stable}
environment:
# ...existing vars...
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 # default 127.0.0.1 is unreachable from the sidecar
- N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
task-runners:
image: n8nio/runners:${N8N_IMAGE_TAG:-stable} # SAME tag as n8n
restart: unless-stopped
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679 # the n8n service name
- N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
# volumes: # only when allowlisting modules (below)
# - ./n8n-task-runners.json:/etc/n8n-task-runners.json:ro
depends_on:
- n8n
networks:
- n8n_net
```
Then `docker compose config -q && docker compose up -d`. n8n is recreated, so expect a few
seconds of downtime.
## Queue mode: one sidecar per worker
> Built from the docs and checked with `docker compose config`. Not yet exercised on a live queue
> cluster. Verify with the `Registered runner` log line on **every** worker before handing off.
The docs are explicit: **each worker needs its own runners sidecar**, and the main needs one too
if `OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=false` (the template sets `true`, so the main doesn't).
A launcher connects to exactly one broker, and every worker is its own broker. That breaks the
template's `deploy.replicas` / `--scale n8n-worker=N` approach. A single `task-runners` service
pointed at `http://n8n-worker:5679` attaches to whichever replica DNS returns first. The other
workers have no runner, so their Code nodes wait out `N8N_RUNNERS_TASK_REQUEST_TIMEOUT`
(60 s) and fail. Scaling the sidecar to the same count doesn't help either: the pairing is random.
Use explicit pairs instead, one per worker:
```yaml
n8n-worker-1:
<<: *n8n
command: worker --concurrency=5
task-runners-1:
image: n8nio/runners:${N8N_IMAGE_TAG:-stable}
restart: unless-stopped
environment:
N8N_RUNNERS_TASK_BROKER_URI: http://n8n-worker-1:5679
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
depends_on: [n8n-worker-1]
networks: [n8n_net]
# n8n-worker-2 + task-runners-2, … — drop `deploy.replicas` from the worker definition
```
- `N8N_RUNNERS_MODE`, `N8N_RUNNERS_BROKER_LISTEN_ADDRESS` and `N8N_RUNNERS_AUTH_TOKEN` are
behavioural. Put them in the `x-n8n-env` anchor so main and workers agree (the parity rule in
`QUEUE_MODE.md`).
- Scaling now means adding a worker plus its runner, not `--scale`.
## Allowlisting modules
**Python: every import is blocked by default**, even `json` or `datetime`. The image's launcher
config pins `N8N_RUNNERS_STDLIB_ALLOW` and `N8N_RUNNERS_EXTERNAL_ALLOW` to empty strings. The
code is rejected before it runs:
```
Security violations detected
Line 1: Import of standard library module 'datetime' is disallowed. Allowed stdlib modules: none
```
Setting those vars on the sidecar container does nothing: the `env-overrides` win, and the docs
say to set them in the config file. The recipe:
```bash
cd <DATA_FOLDER>
# 1. copy the default from the SAME image version you run
docker run --rm --entrypoint cat n8nio/runners:<tag> /etc/n8n-task-runners.json > n8n-task-runners.json
# 2. edit the "python" runner's env-overrides, e.g.
# "N8N_RUNNERS_STDLIB_ALLOW": "json,datetime,re,math,statistics,hashlib,base64,urllib,collections,itertools,functools,uuid,zoneinfo,decimal"
# 3. mount it read-only on the sidecar (the commented `volumes:` lines above), then:
docker compose config -q && docker compose up -d --force-recreate task-runners
```
- A module entry includes its submodules (`urllib` allows `import urllib.parse`). `*` allows the
whole standard library, including `os`, `sys`, `subprocess` and `socket`, so don't use it on a
shared instance.
- **Third-party packages** (`pandas`, `numpy`, …) need a custom image:
`FROM n8nio/runners:<tag>` +
`RUN cd /opt/runners/task-runner-python && uv pip install pandas`. Then list them in
`N8N_RUNNERS_EXTERNAL_ALLOW`. If a package imports modules you haven't listed, set
`N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS=true`, but only for packages you trust.
- **Builtins:** `eval, exec, compile, open, input, breakpoint, getattr, object, type, vars,
setattr, delattr, hasattr, dir, memoryview, __build_class__, globals, locals` are removed by
default. Code that calls one fails at runtime with `name 'eval' is not defined`. Dunder attribute
access (`x.__class__`) and `__import__()` are rejected before execution
(`Security violations detected`). The deny list is `N8N_RUNNERS_BUILTINS_DENY`.
- **JavaScript:** in external mode `NODE_FUNCTION_ALLOW_BUILTIN` / `NODE_FUNCTION_ALLOW_EXTERNAL`
are also `env-overrides` in this file, on the `javascript` runner. The image default is `crypto`
/ `moment`. Set them there, not on the n8n container.
- **Timeout:** n8n warns that the default `N8N_RUNNERS_TASK_TIMEOUT` will drop from 300 s to 60 s
in a future version. If Code nodes legitimately run long, set it explicitly on n8n and on the
sidecar.
The Python *language* rules this imposes (only `_items`/`_item`, bracket access, return shapes)
belong to workflow authors — the `n8n-code-python` skill covers them.
## Verify
```bash
docker compose ps # n8n + task-runners Up, same tag
docker compose logs n8n | grep 'Registered runner' # → "launcher-javascript" AND "launcher-python"
docker compose logs task-runners | tail -5 # idle steady state: "Waiting for launcher's task offer to be accepted..."
```
Smoke test: a workflow with a Code node set to **Python** containing
`return [{"json": {"items": len(_items)}}]`. The first run after the sidecar has been idle takes
~1–2 s (runner cold start); later runs take ~0.4 s.
| Symptom | Cause |
|---|---|
| `Python runner unavailable: Python 3 is missing from this system` | The process that executed is still in internal mode. Queue mode: check the **worker** env, not just the main. |
| Code nodes wait ~60 s, then fail | No runner is connected to that broker: token mismatch, wrong `N8N_RUNNERS_TASK_BROKER_URI`, broker still on `127.0.0.1`, runner/n8n version mismatch, or a queue worker without its own sidecar. |
| `Security violations detected` / `Import of … is disallowed` | Module not in the launcher-config allowlist (see above). |
| `name 'open' is not defined` (or another builtin) | Builtin deny list, working as intended. |
## Upgrades
- Bump the shared tag, then `docker compose pull && docker compose up -d`. n8n and the runners
must move together.
- If you mounted a custom `n8n-task-runners.json`, diff it against the **new** image's default
before switching. A newer launcher may add `allowed-env` entries or runner args that your
stale copy would silently drop.