teachyou.ai academy
← All posts
n8n

Self-Hosting n8n: A Step-by-Step Production Setup Guide

Pramod Dutta · May 28, 2026 · 13 min read

Every automation team eventually asks the same question: do we keep paying per-workflow on n8n Cloud, or do we run it ourselves. The moment you're wiring workflows into internal databases, customer data, or anything with a compliance tag, the answer usually tips toward self-hosting n8n. But self-hosting isn't just "docker run and forget" — it's a small piece of infrastructure you now own, with all the operational duties that come with it. This guide walks through a production-grade setup: architecture decisions, the actual Docker Compose stack, secrets handling, HTTPS, database persistence, backups, webhooks, and how to upgrade without breaking things at 2am.

Why teams self-host n8n

n8n Cloud is a fine starting point, but three things push teams toward self-hosting once workflows get serious.

Data control is the biggest one. If your workflows touch customer PII, financial records, or internal system credentials, routing that data through a third-party's infrastructure adds a compliance surface you may not be allowed to accept. Self-hosting keeps every payload, every credential, and every execution log inside a network boundary you control. For teams in regulated industries (healthcare, fintech, anything under GDPR or HIPAA), this is often not optional.

Cost at scale is the second driver. n8n Cloud pricing scales with workflow executions and active workflows. Once you're running thousands of executions a day — webhook-triggered pipelines, scheduled scraping jobs, AI-agent tool calls — the cloud bill grows in a way that a $20/month VPS running the same workload simply doesn't. A single 4vCPU/8GB box can comfortably run tens of thousands of executions per day. The math tips hard in favor of self-hosting once you're past the hobbyist tier.

Custom node and code access is the third. Self-hosted n8n lets you install community nodes freely, write custom nodes against internal APIs, and use the Code node with fewer restrictions (arbitrary npm packages via NODE_FUNCTION_ALLOW_EXTERNAL, for example). If you're building anything with AI agents, custom tool integrations, or proprietary internal connectors, you'll hit walls on hosted plans that don't exist on your own infrastructure.

And underneath all of it: no vendor lock-in. Your workflows, credentials, and execution history live in a Postgres database and a set of JSON files you control. You can migrate servers, change cloud providers, or fork the whole thing without asking anyone's permission.

The tradeoff: you now own uptime, security, and upgrades

Be honest with yourself before you commit to this path. Self-hosting means:

  • You own uptime. If the container crashes at 3am and a webhook-triggered order-processing workflow fails silently, that's your incident, not a support ticket to n8n's team.
  • You own security. Patching the host OS, rotating credentials, restricting network access, and keeping the n8n version current against CVEs is now your job.
  • You own upgrades. New n8n versions ship every few weeks. Skipping them for six months and then upgrading in one jump is how you end up debugging three breaking changes simultaneously.
  • You own backups. There is no "restore from last Tuesday" button unless you built one.

None of this is prohibitive — thousands of teams run n8n in production successfully — but it's a real commitment. If your team has no one comfortable with Docker, reverse proxies, and basic Linux administration, budget time to build that comfort, or this becomes a liability rather than a cost saver.

Architecture overview

A production n8n stack has four moving parts:

  1. n8n itself — the workflow engine and editor UI, running as a container.
  2. Postgres — persistent storage for workflows, credentials (encrypted), and execution history.
  3. A reverse proxy — Caddy, Nginx, or Traefik, terminating HTTPS and forwarding to n8n.
  4. Optional: Redis + worker containers — if you run n8n in queue mode for horizontal scaling of execution workers, separate from the main process handling the UI and webhook intake.

For most teams, start with the single-instance setup (n8n + Postgres + proxy). Move to queue mode only once you're hitting real concurrency limits — it adds operational complexity you don't need on day one.

Docker Compose setup walkthrough

The core idea: n8n runs as a container, talks to Postgres over the Docker network, and the reverse proxy is the only thing exposed to the internet. n8n itself never binds directly to a public port.

Here's a production-oriented docker-compose.yml:

version: "3.8"

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

  n8n:
    image: docker.n8n.io/n8nio/n8n:1.71.0
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
      - DB_POSTGRESDB_USER=${POSTGRES_USER}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - N8N_HOST=${N8N_HOST}
      - N8N_PROTOCOL=https
      - N8N_PORT=5678
      - WEBHOOK_URL=https://${N8N_HOST}/
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - GENERIC_TIMEZONE=${TZ}
      - N8N_RUNNERS_ENABLED=true
    volumes:
      - n8n_data:/home/node/.n8n
    networks:
      - n8n_net
      - proxy_net

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - proxy_net

volumes:
  postgres_data:
  n8n_data:
  caddy_data:
  caddy_config:

networks:
  n8n_net:
  proxy_net:

A few things worth calling out. Postgres and n8n share a private n8n_net network that never touches the internet. Only Caddy is on the internet-facing proxy_net, and only Caddy publishes ports 80/443. depends_on with a condition: service_healthy check ensures n8n doesn't start racing against a Postgres container that isn't ready yet — a common source of "n8n won't start" confusion on first boot. And the image tag is pinned to a specific version (1.71.0, not latest) — more on why in the upgrade section.

Environment variables and secrets management

Never bake credentials into docker-compose.yml. Use a .env file next to the compose file, keep it out of version control, and reference variables with ${VAR_NAME} syntax as shown above.

A minimal production .env:

# Postgres
POSTGRES_USER=n8n
POSTGRES_PASSWORD=use-a-long-random-value-here
POSTGRES_DB=n8n

# n8n core
N8N_HOST=automation.yourdomain.com
N8N_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
TZ=Asia/Kolkata

# n8n hardening
N8N_BLOCK_ENV_ACCESS_IN_NODE=true
N8N_DISABLE_PRODUCTION_MAIN_PROCESS=false
N8N_SECURE_COOKIE=true

The N8N_ENCRYPTION_KEY deserves special attention — it's the key used to encrypt every stored credential (API keys, OAuth tokens, database passwords) inside n8n's own database. Generate it once with openssl rand -hex 32, store it somewhere durable (a password manager or your secrets vault), and never regenerate it on an existing instance — doing so orphans every saved credential and you'll need to re-enter all of them manually.

For anything beyond a single-server setup, don't let secrets live in a plaintext .env on disk. Pull them from a proper secrets manager at deploy time — Docker Swarm secrets, HashiCorp Vault, AWS Secrets Manager, or even sops-encrypted files decrypted during CI/CD — and inject them as environment variables at container start. At minimum, set restrictive file permissions (chmod 600 .env) and make sure your backup strategy doesn't accidentally ship secrets to an unencrypted off-site location.

Also set N8N_BLOCK_ENV_ACCESS_IN_NODE=true if you don't explicitly need workflows to read host environment variables from the Code node — it closes off a real path for credential leakage if someone builds a malicious or careless workflow.

Reverse proxy with HTTPS

n8n needs HTTPS for two non-negotiable reasons: the editor UI handles credentials over the wire, and most webhook providers (Slack, Stripe, GitHub) refuse to deliver webhooks to plain HTTP endpoints.

Caddy is the easiest option because it handles Let's Encrypt certificate issuance and renewal automatically with almost no configuration. A Caddyfile:

automation.yourdomain.com {
    reverse_proxy n8n:5678 {
        flush_interval -1
    }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
    }
}

The flush_interval -1 setting matters specifically for n8n — it disables response buffering, which is required for the editor's real-time execution streaming to work correctly through the proxy. Without it, you'll see workflow executions appear to hang in the UI even though they completed.

If you're on Nginx instead, the equivalent is ensuring proxy_buffering off; and passing through Upgrade/Connection headers so WebSocket-based push updates work. Traefik is a good choice if you're already running multiple services behind it and want automatic certificate management via Docker labels rather than a static config file.

Point your DNS A record at the server before starting the proxy — Let's Encrypt's HTTP-01 challenge needs the domain resolving correctly, and Caddy will retry silently if it can't issue a cert yet, which can look like a hang on first boot.

Database choice: Postgres over SQLite for production

n8n ships with SQLite as the default database, and it's fine for evaluating the tool on your laptop. It is not fine for production. SQLite is a single-file, single-writer database — under any real concurrency (multiple webhook triggers firing close together, scheduled workflows overlapping with manual test executions), you'll hit database is locked errors that manifest as failed or stuck executions.

Postgres is the recommended production database for three concrete reasons:

  • Concurrent writes. Postgres handles simultaneous execution writes from multiple workflows without locking the entire database.
  • Backup tooling. pg_dump and point-in-time recovery via WAL archiving are mature, well-documented, and don't require stopping the application.
  • Scaling path. If you later move to queue mode with multiple worker containers, they all need a real concurrent-access database — SQLite can't do this at all, so you'd have to migrate anyway.

The compose file above already wires this up via DB_TYPE=postgresdb and the DB_POSTGRESDB_* variables. If you're migrating an existing SQLite instance, n8n has an internal export/import path: run n8n export:workflow --all and n8n export:credentials --all against the old instance, stand up the new Postgres-backed instance, then import. Test this migration on a staging copy first — don't do it live against your only instance.

Backup strategy

A backup strategy for self-hosted n8n covers three distinct things, and people often only remember the first one.

1. The Postgres database — this holds workflows, credentials (encrypted), and execution history. Back it up with a scheduled pg_dump:

docker exec n8n-postgres-1 pg_dump -U n8n n8n | gzip > /backups/n8n-db-$(date +%F).sql.gz

Run this on a cron schedule (daily at minimum, hourly if you're running business-critical workflows), and push the output somewhere off-box — S3, Backblaze, or a separate backup server. A backup that lives on the same disk as the database it's backing up doesn't survive a disk failure.

2. The `N8N_ENCRYPTION_KEY` — back this up separately from the database dump, in a password manager or secrets vault, not in the same location as the SQL dumps. Without this key, a restored database is useless: every credential in it is encrypted ciphertext you can no longer decrypt.

3. The `n8n_data` volume — this contains binary data files, community node installations, and some local configuration outside the database. Snapshot it alongside the Postgres dump, either via docker run --rm -v n8n_data:/data -v /backups:/backup alpine tar czf /backup/n8n-data-$(date +%F).tar.gz /data or your infrastructure's native volume-snapshot tooling.

Test your restore process quarterly at minimum. A backup you've never restored from is a hypothesis, not a backup. Spin up a throwaway staging stack, restore into it, and confirm workflows execute correctly with real credentials before you trust the process in an actual incident.

Webhook URL configuration for external triggers

This is the single most common self-hosting mistake: workflows that use a Webhook trigger node generate URLs based on the WEBHOOK_URL (or N8N_HOST + N8N_PROTOCOL) environment variables, not based on whatever URL you happen to access the editor from. If these aren't set correctly before you build workflows, every webhook URL you copy into Slack, Stripe, or GitHub will point at the wrong host, and external services will fail to reach you with no obvious error on the n8n side.

Set WEBHOOK_URL explicitly to your public HTTPS domain, matching exactly what's configured in the reverse proxy:

WEBHOOK_URL=https://automation.yourdomain.com/

Trailing slash included — n8n concatenates this with the webhook path, and a missing slash produces malformed URLs in some versions. After changing this variable, existing workflows with webhook nodes need to be deactivated and reactivated for the new URL to take effect — n8n registers the webhook path at activation time, not on every execution.

If you're testing locally before deploying, remember n8n distinguishes test URLs (/webhook-test/..., only live while you have the workflow open in the editor and click "Listen for test event") from production URLs (/webhook/..., live once the workflow is activated). A very common support question is "my webhook isn't firing" when the actual issue is the external service was configured with the test URL instead of the production one.

If you're behind Cloudflare or another CDN/proxy in front of your reverse proxy, make sure request bodies aren't being buffered or size-limited in a way that breaks larger webhook payloads (Stripe events, GitHub payloads with big diffs) — this is a frequent silent failure mode.

Upgrade strategy: test before you roll to production

n8n ships new releases frequently, and the changelog occasionally includes breaking changes to node behavior, credential formats, or environment variable defaults. Upgrading production the moment a new version drops, with no testing, is how self-hosted instances end up in a bad state on a Friday afternoon.

The discipline that works:

  1. Pin your image version explicitly. Never run n8n:latest in production — as shown in the compose file, pin to a specific tag like n8n:1.71.0. This means upgrades are a deliberate, reviewed action, not something that happens silently on a container restart.
  2. Run a staging instance. It doesn't need to be big — a small VPS with a copy of your production Postgres database (sanitized of live credentials if it contains sensitive third-party tokens) is enough. Point it at the new image tag first.
  3. Read the release notes. n8n's changelog flags breaking changes clearly. Pay particular attention to changes in node versions — n8n uses per-node versioning, and a major node version bump can change field names or default behavior in ways that silently break a workflow that looked fine in the editor.
  4. Re-run your critical workflows in staging against the new version before touching production. Focus on whichever workflows are business-critical — payment processing, customer notifications, data syncs — not every workflow in the instance.
  5. Roll production forward with a rollback plan. Take a fresh Postgres backup immediately before the upgrade. If the new version misbehaves, you can revert the image tag and restore the pre-upgrade database snapshot.
  6. Upgrade in small increments rather than jumping multiple major versions at once. Skipping from a six-month-old version straight to current means absorbing every breaking change simultaneously, with no way to isolate which change caused which regression.

Subscribe to n8n's release notifications or check the changelog on a fixed cadence (monthly is reasonable for most teams) rather than reactively upgrading only when you need a specific new feature. Staying within a few versions of current keeps each upgrade small and low-risk.

Monitoring and day-two operations

Once the stack is running, a few lightweight practices go a long way. Enable n8n's built-in error workflow feature (a dedicated workflow that runs automatically whenever any other workflow fails) and wire it to a Slack or email notification — this is your cheapest early-warning system for silent failures. Watch container resource usage; the Code node and large binary data workflows (file processing, image handling) are the most common causes of memory pressure, and n8n's execution data pruning settings (EXECUTIONS_DATA_PRUNE and related variables) should be tuned so old execution logs don't slowly fill your Postgres volume. Finally, put the reverse proxy and n8n container behind a basic uptime check (a simple HTTP ping to the login page on a 1-minute interval) so you know about downtime before a user tells you.

Self-hosting n8n is a genuinely good trade for teams that have outgrown the cloud tier or need tighter data control — but it's infrastructure, and infrastructure needs the same operational rigor you'd apply to any other production service: pinned versions, tested backups, monitored uptime, and a deliberate upgrade cadence. Get that foundation right once, and the workflow engine itself mostly stays out of your way.

If you're building automation stacks like this and want to go a layer deeper — wiring self-hosted tools like n8n into agentic AI systems via the Model Context Protocol — that's exactly the ground we cover in "Building & Integrating MCP Servers", where we walk through standing up your own MCP servers alongside infrastructure you control end to end.