background-shape
Self-Hosting n8n for Engineering Teams, A Pragmatic Setup Guide
May 2, 2023 · 6 min read · by Muhammad Amal programming

TL;DR — Run n8n self-hosted with Docker Compose, Postgres, and queue mode if you have more than a couple of engineers hitting it. / SQLite is fine for a laptop demo, miserable in production. / Webhook scaling is the single biggest reason teams outgrow n8n Cloud and the single biggest reason their self-hosted setup falls over.

I’ve spent the last few months migrating a thirty-person engineering org from a tangled mess of cron jobs, Lambda functions, and one very tired Zapier account onto a self-hosted n8n instance. The promise was simple: one place where PMOs, support, and engineers could wire APIs together without rewriting Python every time someone wanted a Slack notification on a Jira transition. The reality, as always, was a bit messier.

What follows is the setup I wish someone had handed me on day one. It assumes you’re comfortable with Docker, you have a Postgres instance you trust, and you understand why running automations on the same node as your production API is a bad idea. If you’re evaluating n8n against Zapier or Power Automate for an engineering team, the short answer is that n8n wins on cost and extensibility once you cross roughly 5,000 task executions a month — but only if you’re willing to operate it yourself.

This post focuses on the boring infrastructure layer. The fun stuff — actually wiring Jira, Linear, and GitHub together — is what the rest of this month’s posts are for.

Why self-host instead of using n8n Cloud

The marketing pitch for n8n Cloud is real. Zero ops, hosted webhooks, a usable free tier. For a solo engineer or a team of five, I’d recommend it without hesitation. The break-even where self-hosting starts to make sense is somewhere around the moment one of these becomes true:

  • You need to call internal APIs that don’t have a public ingress.
  • Your compliance team has opinions about where data lives.
  • You’re approaching the execution limits on the Pro tier and the next jump is painful.
  • You want to write custom nodes in TypeScript and ship them through your normal CI pipeline.

For the team I was working with, all four were true within about six weeks of pilot. We migrated.

The other thing to be clear-eyed about: self-hosting n8n isn’t free. You’re trading a subscription fee for an on-call rotation. Budget for it.

The baseline Docker Compose setup

Here’s the docker-compose.yml I landed on after three iterations. It’s deliberately boring. Postgres for the database, Redis for queue mode, and two n8n processes — one main, one worker.

version: "3.8"

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

  redis:
    image: redis:7.0-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data

  n8n-main:
    image: n8nio/n8n:0.225.2
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: ${POSTGRES_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      EXECUTIONS_MODE: queue
      QUEUE_BULL_REDIS_HOST: redis
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_HOST: ${N8N_HOST}
      N8N_PROTOCOL: https
      WEBHOOK_URL: https://${N8N_HOST}/
      GENERIC_TIMEZONE: Asia/Jakarta
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - n8n_data:/home/node/.n8n

  n8n-worker:
    image: n8nio/n8n:0.225.2
    restart: unless-stopped
    command: worker
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: ${POSTGRES_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      EXECUTIONS_MODE: queue
      QUEUE_BULL_REDIS_HOST: redis
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
    depends_on:
      - n8n-main

volumes:
  postgres_data:
  redis_data:
  n8n_data:

A few non-obvious things. The N8N_ENCRYPTION_KEY encrypts stored credentials at rest in Postgres. Rotate it and every credential breaks, so back it up the way you’d back up a database password — somewhere your bus factor of one can’t accidentally delete. The WEBHOOK_URL has to be your externally reachable HTTPS URL, not the internal Docker hostname, or webhooks generated inside workflows will point at localhost.

Queue mode is the single setting most new self-hosters skip. By default n8n runs everything in-process on the main node, which means a long-running workflow blocks the editor UI and webhook ingestion. Once you turn on EXECUTIONS_MODE=queue, the main process becomes a coordinator and workers pick jobs off Redis. You can scale workers horizontally by adding more n8n-worker replicas behind the same Redis instance.

Hardening for actual production use

The Compose file above gets you running. It does not get you ready for traffic. Here’s what I added before pointing real workflows at it:

TLS and reverse proxy

I’m using Caddy 2.6 in front of n8n. It handles automatic Let’s Encrypt and forwards the right headers so OAuth callbacks work. The minimal Caddyfile:

n8n.example.com {
    reverse_proxy n8n-main:5678
    header X-Frame-Options "SAMEORIGIN"
    header Strict-Transport-Security "max-age=31536000;"
}

Traefik works equally well. The point is: do not expose port 5678 directly. n8n’s auth is fine, but you want a layer in front for rate limiting and TLS termination.

Authentication

For a team larger than four or five people, the built-in basic auth gets old fast. n8n’s owner-account model and user management (introduced in late 2022) is what you want. If you’re on the Enterprise tier, SAML is available; otherwise SSO requires a workaround through your reverse proxy. The official n8n hosting docs cover the user-management setup in detail.

Backups

Two things to back up: the Postgres database (everything except encrypted credentials and workflows lives here) and the N8N_ENCRYPTION_KEY. Without the key, a database restore is useless for decrypting stored credentials. I use pg_dump on a nightly cron, ship it to S3, and store the encryption key in 1Password with break-glass access.

Webhook scaling, the part nobody mentions

The most common production failure I’ve seen with self-hosted n8n is webhook saturation. By default, webhooks are handled by the main process — the same one serving the editor UI. Under load, the UI gets slow, then unresponsive, then the webhook starts timing out and your upstream system (GitHub, Jira, Slack) starts retrying.

The fix is to run dedicated webhook processes:

n8n-webhook:
  image: n8nio/n8n:0.225.2
  restart: unless-stopped
  command: webhook
  environment:
    EXECUTIONS_MODE: queue
    DB_TYPE: postgresdb
    # ... same DB and Redis config as main
    N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
  deploy:
    replicas: 2

Then point your reverse proxy at the webhook service for /webhook/* paths and at the main service for everything else. This single change took our P99 webhook latency from 2.4 seconds (with periodic 30-second spikes) down to a stable 180ms.

Common Pitfalls

A few traps I or someone on my team hit:

  • SQLite default. If you don’t set DB_TYPE=postgresdb, n8n silently uses SQLite. It’ll work fine until you try to scale workers, at which point you’ll have a wonderful afternoon debugging file-lock contention.
  • Timezone drift. GENERIC_TIMEZONE only affects the schedule trigger node. Cron expressions in other places interpret UTC. Don’t ask how I learned this.
  • Credential leakage in exports. When you export a workflow as JSON to commit to git, credentials are stripped — but the credential names and IDs are exposed. Don’t name a credential prod-stripe-key-real. Future you will be embarrassed.
  • Memory limits. The default Node heap is 4 GB on the n8n image. Large CSV processing or wide Postgres queries can OOM the worker. Set NODE_OPTIONS=--max-old-space-size=8192 if you’re processing real data volumes.
  • The Function node and npm packages. By default, the Function node has no access to external npm modules. You can allowlist them with NODE_FUNCTION_ALLOW_EXTERNAL=lodash,axios, but this is also a security boundary. Be deliberate.

Wrapping Up

n8n self-hosted is one of those tools that rewards being a bit boring about the infrastructure. Postgres, Redis, queue mode, dedicated webhook workers, and a reverse proxy in front. None of it is novel, all of it matters. Once you’ve got a stable platform, the interesting work starts: actually automating the workflows your team is currently doing by hand.

Next post in this series digs into the first big one — driving Jira from scripts with the REST v3 API in a way that doesn’t make you want to throw the API spec into the sea.