SSD Nodes Learn
How to do am Matt ConnorBy Matt Connor · Updated 2026-07-24

how to host n8n for VPS with Docker

Learn how to setup n8n with Docker Compose and Postgres. We go show you how to fix WEBHOOK_URL and encryption-key errors for HTTPS setup.

Wetin you dey build

n8n na workflow automation tool: na visual editor wey trigger — like webhook, schedule, or form submission — dey start chain of nodes wey dey call APIs, change data, and write to other systems. E don become standard glue for AI-agent workflows because e fit talk to every model provider and database without you write any service. One docker run fit get working editor inside two minutes. Dis guide na about de remaining ninety percent: how to make am strong by using Postgres instead of de default SQLite file, how to make e reachable over HTTPS, and — de part wey almost everybody dey mess up — how to make webhooks give out URL wey de outside world fit reach.

De final stack na two containers for one Docker network: n8n itself, and one Postgres database wey go hold all workflows and credentials. One reverse proxy for de host go handle TLS and forward everything to n8n for localhost, so nothing go face de internet except through dat proxy. E dey sit alongside de other services for 2026 self-hosting shortlist.

Prerequisites, and the honest limits

You need VPS wey get at least 1 GB of RAM; plan for 2 GB once workflow start to work heavy, because executions plus the Node.js runtime go chop memory. If out-of-memory killer kill the container mid-run, e go hard to learn from that mistake. One single vCPU dey okay to start.

You need domain or subdomain — like n8n.example.com — wey get A record wey point to VPS public IP. This one must resolve before you request certificate. You must open port 80 and 443 for proxy; n8n port 5678 must not face the internet. You need Docker Engine and the Compose plugin; if docker compose version give you docker: 'compose' is not a docker command error, e mean say you get old standalone binary, and the plugin na sudo apt install docker-compose-plugin.

SQLite fine for test, but use Postgres for anything wey you rely on

n8n default database na SQLite file wey dey /home/node/.n8n/database.sqlite. If you just wan test am, e fine — if you no mount volume, you go lose everything as soon as you recreate the container, and na lesson for you. The reason to move to Postgres no be just for speed; na because SQLite dey use single writer lock. This mean say if one instance dey run many workflows at once, or if you use queue mode wey you go eventually need, SQLITE_BUSY: database is locked go dey happen when many things dey run together. Postgres no get that kind limit, e dey backup well with pg_dump, and na wetin n8n docs dey assume for any server wey you depend on. If you switch later, you go need to migrate data by hand, so if this box important to you, start with Postgres.

DNS and the firewall

First, point the record and open the ports. If you no do this, the certificate step go fail because the name no go resolve.

dig +short n8n.example.com
curl -s ifconfig.me
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow OpenSSH
sudo ufw enable

No open 5678. The compose file bind n8n to 127.0.0.1:5678 so only the host's reverse proxy fit reach am. If you open am, ufw allow 5678 go spoil that isolation.

The Compose file

Create one working directory and one docker-compose.yml. This na the whole stack — two services, one private network, and two named volumes.

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

  n8n:
    image: docker.n8n.io/n8nio/n8n:2.29.10
    restart: unless-stopped
    ports:
      - "127.0.0.1:5678:5678"
    environment:
      - N8N_HOST=n8n.example.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.example.com/
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_PROXY_HOPS=1
      - GENERIC_TIMEZONE=Europe/London
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
    volumes:
      - n8n_data:/home/node/.n8n
    networks:
      - n8n_net
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
  n8n_data:

networks:
  n8n_net:

Some things wey we must state clear. DB_POSTGRESDB_HOST=postgres na the service name, which Docker go use for the shared network — no be localhost, wey inside the n8n container mean n8n itself. The depends_on with condition: service_healthy dey make sure say n8n no race Postgres when e dey boot; if you no use am, n8n go start, e no go see database, and e go exit. The named volume n8n_data for /home/node/.n8n dey hold the encryption key and, for SQLite, the database — na that one directory wey you must no lose. Pin the image to one exact version, no use latest; the reason dey for the upgrade section below.

The secrets file

No ever put passwords inside the compose file. Put dem for one .env file wey dey next to am so Compose fit read am automatically. Make sure say you generate dem so dem go truly random.

printf 'POSTGRES_PASSWORD=%s\n'  "$(openssl rand -hex 24)" >  .env
printf 'N8N_ENCRYPTION_KEY=%s\n' "$(openssl rand -hex 32)" >> .env
chmod 600 .env

The N8N_ENCRYPTION_KEY na the most important string for here — na him be the key wey dem dey use encrypt every stored credential. Set am yourself instead of to let n8n generate one, because if you generate am, you fit write am down and restore am later. Once n8n don use this key encrypt its first credential, if you change am, every credential go become impossible to decrypt — so set am once, now, and no ever touch that line again.

Di env vars wey dey decide if webhooks go work

Four variables dey control how n8n dey tell di outside world about itself. If you set dem wrong, na di number-one n8n support question.

  • N8N_HOST na di public hostname, n8n.example.com. If you leave am as di default localhost behind a proxy, di editor go try load its own API from localhost inside your browser, and dat one go fail.
  • N8N_PROTOCOL=https dey tell n8n say e dey run over TLS, so e go mark its session cookie Secure and build https:// URLs.
  • N8N_PORT=5678 na di port wey n8n dey listen on inside di container. E no be di public port; di proxy dey hold 443.
  • WEBHOOK_URL=https://n8n.example.com/ na di one wey dey cause wahala. n8n dey print di webhook addresses wey you go paste for Stripe, GitHub, or any external caller by building dem from dese values. If you no set am or if e wrong, n8n go use N8N_HOST:N8N_PORT instead, and e go give you https://n8n.example.com:5678/webhook/... or, even worse, http://localhost:5678/webhook/... — e go print am without error, e go look correct, but internet no fit reach am, so di caller requests no go ever arrive. Set am to di exact public base URL with di trailing slash, den confirm say di webhook node show URL wey no get port.

N8N_PROXY_HOPS=1 dey tell n8n's Express server make e trust one proxy wey dey front, so dat way rate-limiting and any feature wey dey read client IP go see di real address instead of di proxy address. One variable wey you must not set here na N8N_RUNNERS_ENABLED: task runners — wey be how n8n dey run Code-node logic inside separate sandboxed process — don be di default since 1.69, and dem mandatory from di 2.x line wey dis guide pin, so di old opt-in don expire. Set am now, and n8n go just log one notice tell you make you remove am.

First start

docker compose up -d
docker compose ps
docker compose logs -f n8n

If first boot dey work well, you go see Editor is now accessible via: line, and n8n ready on ..., port 5678 line go dey above am. docker compose ps suppose show both container Up, and postgres must dey marked (healthy). If n8n enter Restarting loop, check the logs — usually na database connection or volume permissions wey we talk for below.

TLS with a reverse proxy

n8n dey use plain HTTP for port 5678; something for front must handle HTTPS. You get two easy ways.

If you don dey run many containers already, put n8n behind a Traefik reverse proxy wey dey issue TLS certificates automatically with some labels — Traefik go request and renew the certificate for you.

If na this be only app for the box, nginx virtual host with Let's Encrypt certificate go easy pass. Use the Certbot and nginx TLS setup for Ubuntu 24.04 take get the certificate, then use this server block:

server {
    listen 443 ssl;
    server_name n8n.example.com;

    ssl_certificate     /etc/letsencrypt/live/n8n.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/n8n.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:5678;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600;
        client_max_body_size 16m;
    }
}

You must use Upgrade and Connection "upgrade" headers; dem no be optional. n8n dey send live execution updates to the editor via WebSocket, and if those two lines no dey, the login page go load but e go hang with "lost-connection" banner. proxy_read_timeout 3600 dey stop nginx to cut long-running executions for dem default 60 seconds. X-Forwarded-Proto $scheme header na partner for N8N_PROXY_HOPS=1: e dey tell n8n say the original request na HTTPS even though the proxy dey reach am via plain HTTP, so n8n no go think say connection no secure and reject its own cookie.

Your first workflow, to make it real

Open https://n8n.example.com/, create the owner account (next section), and build the smallest workflow wey go prove say the path dey work: a webhook in, an HTTP call, and a response out.

  1. Add a Webhook node. Set the method to POST and a path like hello. E go show two URLs, a Test URL and a Production URL — na dis two dey cause half of the "my webhook doesn't work" wahala. The Test URL go answer only one call, and na only when you don click Listen for test event; after dat, e go expire. The Production URL go answer whenever the workflow dey Active.
  2. Add an HTTP Request node after am, point am at any public JSON API — if you do GET to https://api.github.com/zen, e go return one-line string, and dat one enough.
  3. Add a Respond to Webhook node, and set the Webhook node Respond option to "Using Respond to Webhook node" so dat person wey call am go get the HTTP node output back.
  4. Toggle the workflow to Active (top right) and call am: curl -X POST https://n8n.example.com/webhook/hello. You go see dat zen line come back — POST in, API call, response out, na dis be how most real automations dey run.

One scheduled variant na to swap the Webhook node for a Schedule Trigger and call a model endpoint instead — to use Ollama wey dey run for di same VPS na fine way to build nightly summariser.

User management, no be basic auth

Old n8n guides go tell you say make you set N8N_BASIC_AUTH_ACTIVE=true. Dem don remove those variables for n8n 1.0 and dem no dey work again. Authentication today na the owner account: the first time you load the editor, n8n go make you create email-and-password owner, and that gate na mandatory — no be anonymous mode. Create am immediately after first boot, before you give anybody the URL: between docker compose up and that first form submission, whoever reach the instance first fit claim am. To put reverse-proxy basic-auth layer for top na good extra lock, but na second factor dat, no be the real authentication.

Backups: first na encryption key, den na database

Two things you need backup, and dem no be the same level.

The N8N_ENCRYPTION_KEY. Every credential wey you store for n8n — API tokens, database passwords, OAuth secrets — dey encrypted at rest with this key. The workflows inside Postgres no go work without am: if you restore the database for new box wey get different key, n8n no go fit decrypt any single credential, and no way to recover or reset am. Your .env file na him hold the key; copy am go somewhere outside the server — using password-manager na the best way — the same day you create am. This na the backup wey really matter.

The Postgres database, for the workflows, execution history and the encrypted credentials themselves:

docker compose exec -T postgres pg_dump -U n8n -d n8n \
  | gzip > n8n-db-$(date +%F).sql.gz

Run that command for schedule and copy the dump off the box. To restore for fresh VPS: start the stack once so the database go exist, stop n8n, load the dump back with psql, put the same N8N_ENCRYPTION_KEY into .env, and start n8n. Same key plus the dump na working instance; new key na workflows wey no go fit use any single credential.

Upgrades: pin the tag

Compose file pin n8nio/n8n:2.29.10 instead of latest by design. n8n dey release new minor version almost every week. Sometimes dem dey change database schema or how node dey behave for between these versions. So latest mean say if you allow automatic pull, the system fit start migration for your database as soon as e start. Pin one version, read the release notes before you change am — n8n dey list breaking changes for there — and upgrade with care:

docker compose exec -T postgres pg_dump -U n8n -d n8n | gzip > pre-upgrade.sql.gz
# edit the image tag in docker-compose.yml, then:
docker compose pull n8n
docker compose up -d n8n
docker compose logs -f n8n

Major-version jumps na where this matter dey most important. For example, 2.0 line change N8N_BLOCK_ENV_ACCESS_IN_NODE to true by default. Any Code node wey dey read process.env go lose access without warning until you set am back to false; that same release start to enforce strict permissions for settings file. Read the 2.0 breaking-changes page before you cross major boundary. n8n dey run any needed database migrations automatically when e start — na why the pre-upgrade pg_dump no be optional. Because credentials dey inside .env with encryption key and data dey inside Postgres, you fit discard the containers: to upgrade, just replace dem, and to roll back, just pin the old tag and restore the dump.

Failure modes, with the strings you will see

The requested webhook "POST hello" is not registered. A 404 error dey happen if you call webhook wey workflow no be Active, or if you call test path when nobody dey listen. Test paths (/webhook-test/...) go answer only when you don click "Listen for test event"; production paths (/webhook/...) go answer only when workflow toggle dey ON. The sibling This webhook is not registered for GET requests. Did you mean to make a POST request? mean say the method wrong — the node dey expect POST but you send GET.

The webhook URL dey show :5678 or localhost. The node dey show https://n8n.example.com:5678/webhook/... or http://localhost:5678/.... WEBHOOK_URL unset or wrong, so n8n build the address from N8N_HOST:N8N_PORT instead of your public base. Set WEBHOOK_URL=https://n8n.example.com/, recreate the container with docker compose up -d, and the port go disappear.

There was a problem loading init data for browser. The editor load but e no fit reach its own backend API. If you dey use proxy, this one na almost always because N8N_HOST or WEBHOOK_URL wrong, or proxy no dey pass WebSocket Upgrade headers, or N8N_PROTOCOL no match how you dey connect. Check the four public-facing variables and make sure say proxy dey forward Upgrade and Connection.

password authentication failed for user "n8n" for logs, and the container dey restart. The password wey n8n dey send no match the one wey dem use initialise the database. The trap: Postgres dey read POSTGRES_PASSWORD only when e dey initialise empty data directory. Start the stack once, then change POSTGRES_PASSWORD for .env, and the existing postgres_data volume go still carry the old password. Change am back to the original, or, if you no get data wey you wan keep, docker compose down and docker volume rm the postgres volume, then start am fresh.

EACCES: permission denied, open '/home/node/.n8n/config' when e dey start. n8n dey run as the node user (UID 1000) and e no fit write for its config directory. This one dey affect people wey dey bind-mount host folder (./n8n_data:/home/node/.n8n) wey root own. Use the named volume wey we show above, or if you insist on bind mount, sudo chown -R 1000:1000 ./n8n_data first.

Permissions 0644 for n8n settings file /home/node/.n8n/config are too wide. Changing permissions to 0600.. From the 2.x line, n8n dey enforce 0600 for that settings file by default and e dey fix am itself when e dey boot — this log line mean say e don correct the mode already, usually after bind mount or after restore copy the file back with loose permissions. You no need do anything; set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false only if your filesystem no fit support permissions at all.

Mismatching encryption keys — the full line dey say the encryption key for the settings file /home/node/.n8n/config no match the N8N_ENCRYPTION_KEY for your environment. The key for your environment different from the one n8n write for its data volume before — most times na because n8n generate random key for earlier boot when the variable unset, and you later set different one. Put the original key back for .env, or, only if you truly no get stored credentials wey worth keeping, delete the config file inside the n8n_data volume and let n8n regenerate am — but know say existing credentials go become unreadable.

A login banner about secure cookies: Your n8n server is configured to use a secure cookie, however you are either visiting this via an insecure URL, or using Safari. You set N8N_PROTOCOL=https but you reach n8n via plain HTTP — usually by you dey hit the IP and port directly instead of the HTTPS proxy. Reach am via https://n8n.example.com/. Only if you really no fit use HTTPS you should set N8N_SECURE_COOKIE=false, and no ever do am for box wey dey face internet.

To put a language model inside those workflows, see building AI workflows with Claude and n8n.

FAQ

I suppose I go use SQLite or Postgres for n8n?

SQLite (wey be default) fine if you just wan try n8n or if na personal instance wey dey run one workflow at a time. Move to Postgres if you get serious work: SQLite single writer lock dey cause database is locked when many things dey happen at once, and Postgres dey back up well with pg_dump. Migration later na manual work, so if the server matter to you, start with Postgres.

Why my n8n webhooks no dey fire?

Mostly na WEBHOOK_URL. If you no set am or if e wrong, n8n go print webhook addresses wey dem build from N8N_HOST:N8N_PORT — often with :5678 or localhost inside — wey go look valid but internet no fit reach am, so the caller requests no go ever arrive. Set WEBHOOK_URL=https://n8n.example.com/ and make sure say the node show URL wey no get port. The second reason na if you dey call webhook wey workflow no be Active, which go return The requested webhook ... is not registered.

Wetin I must back up for n8n?

Two things. The N8N_ENCRYPTION_KEY from your .env file, because every stored credential dey encrypted with am and if you lose am, you no go fit decrypt them again — copy am off the server the day you create am. And a pg_dump of the Postgres database for the workflows, history and credentials. To restore, you go need both: the same key plus the dump.

How I go put n8n behind HTTPS?

n8n dey serve plain HTTP on port 5678; reverse proxy for front dey handle TLS. Bind n8n to 127.0.0.1:5678 so only the proxy go fit reach am, then use Traefik with automatic certificates or nginx with a Let's Encrypt certificate. Set N8N_PROTOCOL=https and WEBHOOK_URL=https://your-host/, and make sure say the proxy dey forward the WebSocket Upgrade headers or the editor go hang.

How I go upgrade n8n safely?

Pin one specific image tag instead of latest, take a pg_dump first because n8n dey run migrations automatically when e start, read the release notes for any breaking changes, then change the tag and run docker compose pull n8n && docker compose up -d n8n. The container no dey stay, so if things spoil, roll back by pin the previous tag and restore the pre-upgrade dump.