SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-27

How to self-host n8n for VPS with Docker and HTTPS

Run n8n for VPS with Docker Compose, Postgres, and HTTPS. Avoid WEBHOOK_URL and N8N_ENCRYPTION_KEY traps, plus fix every common error string.

Wetín you dey build

n8n na workflow automation tool: visual editor wey trigger, webhook, schedule, or form submission fit start chain of nodes wey call APIs, reshape data, and write am to other systems. E don become the default glue for AI-agent workflows because e fit talk to every model provider and database without you writing service. One docker run go give you working editor for two minutes. This guide na about the other ninety percent: how to make am durable with Postgres instead of the default SQLite file, make am reachable over HTTPS, and, the part wey almost everybody dey get wrong, make webhooks give out URL wey outside world fit actually reach.

The complete stack na two containers for one Docker network: n8n itself, and Postgres database wey dey hold its workflows and credentials. Reverse proxy for the host go terminate TLS and forward traffic to n8n for localhost, so nothing go face internet except through that proxy. E go sit beside the other services for the 2026 self-hosting shortlist.

Prerequisites, and the honest limits

You need VPS wey get at least 1 GB RAM. Plan for 2 GB once workflows start doing real work, because executions plus Node.js runtime dey chop memory. If out-of-memory killer stop the container while e dey run, na bad way to discover this. One vCPU dey okay to start.

If this box go also run something wey heavy, size am for that service first. Photo library na the usual cause. The actual RAM minimums for PhotoPrism and Immich dey far above anything n8n need.

Same thing apply to media box. Jellyfin server plus browsable front end for am, like Halcyon, wey rebuild the library like 90s rental shop, go use the RAM and transcoding headroom long before n8n notice am.

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

SQLite dey okay for test, Postgres for anything wey you rely on

n8n default database na SQLite file for /home/node/.n8n/database.sqlite. E dey okay for testing things. But if you no mount volume, you go lose am when you recreate the container for the first time. That one alone na lesson. The reason to move go Postgres no be raw speed. Na because SQLite fit hold only one writer lock. So instance wey dey run several workflows at the same time, or queue mode wey you go eventually need, go throw SQLITE_BUSY: database is locked when concurrency high. Postgres no get that ceiling. E fit back up cleanly with pg_dump. Na also wetin n8n own docs assume for server wey you depend on. If you switch later, you go need migrate the data by hand. So if this box important, start with Postgres.

DNS and firewall

Point the record and open the ports first, so the certificate step later no fail because the name no 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 na only the host reverse proxy fit reach am, and a ufw allow 5678 go cancel that isolation.

Compose file

Create working directory and one docker-compose.yml. Na the whole stack be this: 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:

Make we state some important decisions clearly. DB_POSTGRESDB_HOST=postgres na the service name wey Docker dey resolve for the shared network. E no be localhost, because inside the n8n container, that one mean n8n itself. The depends_on together with condition: service_healthy stop n8n from trying to start before Postgres don ready during boot. Without dem, n8n go start, e no go find database, then e go exit. The named volume n8n_data for /home/node/.n8n dey hold the encryption key and, for SQLite, the database. Na the one directory wey you must not lose. Pin the image to exact version. Never use latest. The reasons dey for the upgrade section below.

The secrets file

Never put passwords inside the compose file. Put dem for one .env file wey dey beside am, wey Compose dey read automatically. Generate the passwords 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 single most important string for here. Na the key wey dem dey use encrypt every stored credential. Set am yourself instead make n8n generate am, because value wey you generate fit write down and restore. Once n8n don encrypt the first credential with this key, if you change am, no credential go decrypt again. So set am once, now, and never touch that line again.

The environment variables wey decide whether webhooks go work

Four variables dey control how n8n dey describe itself to outside world, and if dem wrong, na the number-one n8n support question be that.

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

N8N_PROXY_HOPS=1 dey tell n8n's Express server to trust one proxy wey dey in front of am, so rate-limiting and any feature wey dey read client IP go see the real address instead of the proxy own. One variable wey you deliberately no set here na N8N_RUNNERS_ENABLED: task runners, wey be n8n running Code-node logic inside separate sandboxed process, don dey default since 1.69 and dem mandatory from the 2.x line wey this guide pin, so the old opt-in don deprecated. If you set am now, n8n go only log notice wey tell you make you remove am.

First start

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

Healthy first boot dey end with an Editor is now accessible via: line, and an n8n ready on ..., port 5678 line dey above am. docker compose ps suppose show both containers Up, with postgres marked (healthy). If n8n dey inside Restarting loop, read the logs. Na almost always database connection or volume permissions wey we cover below dey cause am.

TLS with reverse proxy

n8n dey speak plain HTTP for 5678; something dey front dey terminate HTTPS. Two clean choice dey.

If you don dey run several containers already, put n8n behind Traefik reverse proxy wey dey issue TLS certificates automatically with small number of labels. Traefik go request and renew the certificate for you.

If na only app wey dey run for the server, nginx virtual host with Let's Encrypt certificate dey simpler. Use Certbot and nginx TLS setup for Ubuntu 24.04 to obtain 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;
    }
}

The Upgrade and Connection "upgrade" headers no be optional. n8n dey push live execution updates go the editor through WebSocket. Without those two lines, login page go load, then e go hang with lost-connection banner. proxy_read_timeout 3600 dey stop long-running executions from nginx default 60 seconds timeout. The X-Forwarded-Proto $scheme header na companion to N8N_PROXY_HOPS=1: e tell n8n say the original request na HTTPS, even though proxy reach am through plain HTTP. That way, n8n no go decide say the connection insecure and reject im own cookie.

Your first workflow, make e 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: webhook come in, HTTP call happen, response come 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 this one dey cause plenty reports say "my webhook no dey work". The Test URL go answer one call, and only while you don click Listen for test event; after that, e go expire. The Production URL go answer anytime the workflow dey Active.
  2. Add an HTTP Request node after am, point am to any public JSON API. A GET to https://api.github.com/zen go return one-line string, and that one enough.
  3. Add a Respond to Webhook node. Set the Webhook node Respond option to "Using Respond to Webhook node" so the caller go receive the HTTP node output back.
  4. Toggle the workflow Active (top right) and call am: curl -X POST https://n8n.example.com/webhook/hello. You suppose get the zen line back. POST go enter, API call go happen, response go come out. Na the shape of most real automations.

For scheduled version, replace the Webhook node with a Schedule Trigger and call a model endpoint instead. A self-hosted one from Ollama wey dey run for the same VPS na tidy way to build nightly summariser.

User management, no be basic auth

Old n8n guides dey tell you make you set N8N_BASIC_AUTH_ACTIVE=true. n8n 1.0 remove those variables, and dem no dey do anything again. Authentication now na the owner account: the first time you load the editor, n8n go make you create owner account with email and password, and this gate na compulsory; anonymous mode no dey. Create am immediately after first boot, before you give anybody the URL: between docker compose up and that first form submission, anybody wey reach the instance first fit claim am. Reverse-proxy basic-auth layer on top fit serve as extra lock, but na second factor, e no be the real authentication. The owner account and everything else for this guide dey run on the free community edition; if later you want extra users with granular roles, or SSO, read which n8n features need paid licence before you plan around dem.

Backups: encryption key first, then database

Two things need backup, and dem no get the same level of replaceability.

The N8N_ENCRYPTION_KEY. Every credential wey you store for n8n, API tokens, database passwords, and OAuth secrets, dey encrypted at rest with this key. The workflows for Postgres no get use without am: if you restore the database for new box with different key, n8n no fit decrypt even one credential. Recovery or reset no dey possible. Your .env file get the key; copy am go somewhere outside the server on the same day wey you create am. Password-manager entry na good option. Na this backup 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 am on a schedule and copy the dump comot from the box. To restore for fresh VPS: bring the stack up once so the database go exist, stop n8n, load the dump back with psql, put the same N8N_ENCRYPTION_KEY inside .env, then start n8n. Same key plus the dump na working instance; new key go leave workflows wey no fit use even one credential.

Upgrades: pin the tag

The compose file pin n8nio/n8n:2.29.10 instead of latest for good reason. n8n dey release new minor version most weeks, and sometimes e dey change database schema or node behaviour between versions. So latest mean say unattended pull fit give you build wey go migrate your database immediately e start. Pin one version, read the release notes before you increase the version. n8n dey list breaking changes there. Then upgrade deliberately:

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 pass. For example, 2.0 line change N8N_BLOCK_ENV_ACCESS_IN_NODE to true by default. So any Code node wey read process.env go silently lose access until you set am back to false. The same release start enforcing strict permissions for the settings file. Read the 2.0 breaking-changes page before you cross any major-version boundary. n8n dey run any required database migrations automatically when e start. Na exactly why the pre-upgrade pg_dump no be optional. Because credentials dey encrypted with a key inside .env, and the data dey inside Postgres, the containers dey disposable. You upgrade by replacing dem, and roll back by pinning the previous tag and restoring the dump.

Failure modes, and the strings wey you go see

The requested webhook "POST hello" is not registered. 404 wey come when you call webhook wey e workflow no dey Active, or when you call test path while nobody dey listen. Test paths (/webhook-test/...) go answer only while 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 method wrong: node dey expect POST but you send GET.

Webhook URL dey show :5678 or localhost. Node dey display https://n8n.example.com:5678/webhook/... or http://localhost:5678/.... WEBHOOK_URL no set or e 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. Editor load, but e no fit reach e own backend API. Behind proxy, na almost always wrong N8N_HOST or WEBHOOK_URL, proxy wey no forward WebSocket Upgrade headers, or N8N_PROTOCOL wey no match how you connect. Confirm the four public-facing variables and say proxy dey forward Upgrade and Connection.

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

EACCES: permission denied, open '/home/node/.n8n/config' when e start. n8n dey run as the node user (UID 1000), and e no fit write to e config directory. This one dey happen to people wey bind-mount host folder (./n8n_data:/home/node/.n8n) wey root own. Use the named volume wey dey above, or if you insist on bind mount, run 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 dey fix am by itself during boot. This log line mean say e don already correct the mode, commonly 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 truly no fit support permissions.

Mismatching encryption keys, the full line talk say encryption key for settings file /home/node/.n8n/config no match the N8N_ENCRYPTION_KEY for your environment. The key for your environment different from the one wey n8n write inside e data volume for earlier run. Most times, n8n generate random key during earlier boot when the variable no set, then you set another one later. Put the original key back inside .env, or, only if you truly no get stored credentials wey worth keeping, delete the config file inside n8n_data volume and allow n8n regenerate am. Accept say existing credentials go become unreadable.

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 through plain HTTP, usually because you use IP and port directly instead of HTTPS proxy. Reach am through https://n8n.example.com/. Only if you genuinely no fit use HTTPS, set N8N_SECURE_COOKIE=false, and never use am for box wey dey face internet.

To put language model inside those workflows, see how to build AI workflows with Claude and n8n.

FAQ

I suppose use SQLite or Postgres for n8n?

SQLite (the default) dey okay to test n8n and for personal instance wey dey run one workflow at a time. Move go Postgres for anything wey you depend on: SQLite single writer lock dey throw database is locked when concurrency dey, and Postgres dey back up cleanly with pg_dump. Migration later na manual work, so if the server important, start with Postgres.

Why n8n webhooks no dey ever fire?

Almost always na WEBHOOK_URL. If e no set or e wrong, n8n dey print webhook addresses wey build from N8N_HOST:N8N_PORT, often with :5678 or localhost inside, wey look valid but internet no fit reach dem, so caller requests no dey arrive. Set WEBHOOK_URL=https://n8n.example.com/ and confirm say the node dey show URL wey no get port. The second cause na calling webhook wey the workflow no dey toggled Active, and e dey 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 e dey encrypt every stored credential and if e loss, dem no fit decrypt again permanently; copy am away from the server the same day you create am. And a pg_dump of the Postgres database for the workflows, history, and credentials. Restore need both: the same key plus the dump.

How I go put n8n behind HTTPS?

n8n dey serve plain HTTP for port 5678; reverse proxy for front go terminate TLS. Bind n8n to 127.0.0.1:5678 so only the proxy 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 the proxy forwards the WebSocket Upgrade headers, otherwise the editor go hang.

How I go upgrade n8n safely?

Pin 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 breaking changes, then bump the tag and run docker compose pull n8n && docker compose up -d n8n. The container disposable, so you fit roll back by pinning the previous tag and restoring the dump wey you take before upgrade.