SSD Nodes Learn
Guides Matt ConnorBy Matt Connor

Self-host n8n on a VPS: Docker + HTTPS

Run n8n on a VPS with Docker Compose, Postgres, and HTTPS behind a reverse proxy: the WEBHOOK_URL and encryption-key traps, plus every error string.

What you are building

n8n is a workflow automation tool: a visual editor where a trigger — a webhook, a schedule, a form submission — fires a chain of nodes that call APIs, reshape data and write to other systems. It has become the default glue for AI-agent workflows because it speaks to every model provider and database without you writing a service. One docker run gets a working editor in two minutes. This guide is about the other ninety percent: making it durable with Postgres instead of the default SQLite file, reachable over HTTPS, and — the part almost everyone gets wrong — making webhooks hand out a URL the outside world can actually reach.

The finished stack is two containers on one Docker network: n8n itself, and a Postgres database holding its workflows and credentials. A reverse proxy on the host terminates TLS and forwards to n8n on localhost, so nothing faces the internet except through that proxy. It sits alongside the other services on the 2026 self-hosting shortlist.

Prerequisites, and the honest limits

You need a VPS with at least 1 GB of RAM; plan for 2 GB once workflows do real work, because executions plus the Node.js runtime eat memory and the out-of-memory killer taking the container mid-run is a miserable way to learn that. A single vCPU is fine to start.

You need a domain or subdomain — say n8n.example.com — with an A record pointing at the VPS public IP that resolves before you request a certificate. Ports 80 and 443 must be open to the proxy; n8n's own port 5678 must not face the internet. You need Docker Engine and the Compose plugin; if docker compose version errors with docker: 'compose' is not a docker command you have the old standalone binary, and the plugin is sudo apt install docker-compose-plugin.

SQLite is fine for a test, Postgres for anything you rely on

n8n's default database is a SQLite file at /home/node/.n8n/database.sqlite. For kicking the tyres it is fine — mount no volume and you lose it on the first container recreate, which is its own lesson. The reason to move to Postgres is not raw speed; it is that SQLite holds a single writer lock, so an instance running several workflows at once, or the queue mode you will eventually want, throws SQLITE_BUSY: database is locked under concurrency. Postgres has none of that ceiling, backs up cleanly with pg_dump, and is what n8n's own docs assume for a server you depend on. Switching later means migrating data by hand, so if this box matters, start on Postgres.

DNS and the firewall

Point the record and open the ports first, so the certificate step later does not fail on a name that does not 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

Do not open 5678. The compose file binds n8n to 127.0.0.1:5678 so only the host's reverse proxy can reach it, and a ufw allow 5678 would undo that isolation.

The Compose file

Create a working directory and a docker-compose.yml. This is the whole stack — two services, one private network, 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:

A few decisions worth stating outright. DB_POSTGRESDB_HOST=postgres is the service name, which Docker resolves on the shared network — not localhost, which inside the n8n container means n8n itself. The depends_on with condition: service_healthy stops n8n racing Postgres on boot; without it n8n starts, finds no database, and exits. The named volume n8n_data at /home/node/.n8n holds the encryption key and, on SQLite, the database — the one directory you must not lose. Pin the image to an exact version, never latest; the reasons are in the upgrade section below.

The secrets file

Never put passwords in the compose file. Put them in a .env file next to it that Compose reads automatically, and generate them so they are actually 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 is the single most important string here — it is the key every stored credential is encrypted with. Set it explicitly rather than letting n8n generate one, because a value you generated is one you can write down and restore. Once n8n has encrypted its first credential with this key, changing it makes every credential undecryptable — so set it once, now, and never touch that line again.

The env vars that decide whether webhooks work

Four variables control how n8n describes itself to the outside world, and getting them wrong is the number-one n8n support question.

  • N8N_HOST is the public hostname, n8n.example.com. Leave it at the default localhost behind a proxy and the editor tries to load its own API from localhost in your browser, which fails.
  • N8N_PROTOCOL=https tells n8n it is served over TLS, so it marks its session cookie Secure and builds https:// URLs.
  • N8N_PORT=5678 is the port n8n listens on inside the container. It is not the public port; the proxy owns 443.
  • WEBHOOK_URL=https://n8n.example.com/ is the one that bites. n8n prints the webhook addresses you paste into Stripe, GitHub or any external caller by building them from these values. If it is unset or wrong, n8n falls back to N8N_HOST:N8N_PORT and hands you https://n8n.example.com:5678/webhook/... or, worse, http://localhost:5678/webhook/... — printed with no error, plausible-looking, and unreachable from the internet, so the caller's requests silently never arrive. Set it to the exact public base URL with the trailing slash, then confirm the webhook node shows a URL with no port.

N8N_PROXY_HOPS=1 tells n8n's Express server to trust one proxy in front of it, so rate-limiting and any feature that reads the client IP see the real address rather than the proxy's. One variable you deliberately do not set here is N8N_RUNNERS_ENABLED: task runners — n8n running Code-node logic in a separate sandboxed process — have been the default since 1.69 and are mandatory from the 2.x line this guide pins, so the old opt-in is deprecated. Set it now and n8n just logs a notice telling you to remove it.

First start

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

A healthy first boot ends with an Editor is now accessible via: line, with an n8n ready on ..., port 5678 line above it. docker compose ps should show both containers Up, with postgres marked (healthy). If n8n sits in a Restarting loop, read the logs — it is almost always the database connection or the volume permissions covered below.

TLS with a reverse proxy

n8n itself speaks plain HTTP on 5678; something in front terminates HTTPS. Two clean choices.

If you already run several containers, put n8n behind a Traefik reverse proxy that issues TLS certificates automatically with a couple of labels — Traefik requests and renews the certificate for you.

If this is the only app on the box, an nginx virtual host with a Let's Encrypt certificate is simpler. Use the Certbot and nginx TLS setup for Ubuntu 24.04 to obtain the certificate, then 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 are not optional. n8n pushes live execution updates to the editor over a WebSocket, and without those two lines the login page loads and then hangs with a lost-connection banner. proxy_read_timeout 3600 keeps long-running executions from being cut at nginx's default 60 seconds. The X-Forwarded-Proto $scheme header is the companion to N8N_PROXY_HOPS=1: it tells n8n the original request was HTTPS even though the proxy reaches it over plain HTTP, so n8n does not decide the connection is insecure 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 that proves the path works: a webhook in, an HTTP call, a response out.

  1. Add a Webhook node. Set the method to POST and a path like hello. It shows two URLs, a Test URL and a Production URL — the source of half the "my webhook doesn't work" reports. The Test URL answers one call, and only while you have clicked Listen for test event; it then expires. The Production URL answers whenever the workflow is Active.
  2. Add an HTTP Request node after it, pointed at any public JSON API — a GET to https://api.github.com/zen returns a one-line string, which is enough.
  3. Add a Respond to Webhook node, and set the Webhook node's Respond option to "Using Respond to Webhook node" so the caller gets the HTTP node's output back.
  4. Toggle the workflow Active (top right) and call it: curl -X POST https://n8n.example.com/webhook/hello. You should get the zen line back — POST in, API call, response out, the shape of most real automations.

A scheduled variant swaps the Webhook node for a Schedule Trigger and calls a model endpoint instead — a self-hosted one from Ollama running on the same VPS is a tidy way to build a nightly summariser.

User management, not basic auth

Older n8n guides tell you to set N8N_BASIC_AUTH_ACTIVE=true. Those variables were removed in n8n 1.0 and do nothing now. Authentication today is the owner account: the first time you load the editor, n8n makes you create an email-and-password owner, and that gate is mandatory — there is no anonymous mode. Create it immediately after first boot, before you hand anyone the URL: between docker compose up and that first form submission the instance is claimable by whoever reaches it first. A reverse-proxy basic-auth layer on top is a reasonable extra lock, but it is a second factor, not the real authentication.

Backups: the encryption key first, then the database

Two things need backing up, and they are not equally replaceable.

The N8N_ENCRYPTION_KEY. Every credential you store in n8n — API tokens, database passwords, OAuth secrets — is encrypted at rest with this key. The workflows in Postgres are useless without it: restore the database onto a new box with a different key and n8n cannot decrypt a single credential, with no recovery and no reset. Your .env file holds the key; copy it somewhere off the server — a password-manager entry is ideal — the day you create it. This is the backup that actually matters.

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 on a schedule and copy the dump off the box. To restore on a fresh VPS: bring the stack up once so the database exists, stop n8n, load the dump back with psql, put the same N8N_ENCRYPTION_KEY into .env, and start n8n. Same key plus the dump is a working instance; a new key is workflows that cannot use a single credential.

Upgrades: pin the tag

The compose file pins n8nio/n8n:2.29.10 rather than latest on purpose. n8n ships a new minor most weeks and occasionally changes database schema or node behaviour between them, so latest means an unattended pull can hand you a build that migrates your database the moment it starts. Pin a version, read the release notes before bumping — n8n calls out breaking changes there — and 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 are where this matters most. The 2.0 line, for instance, flipped N8N_BLOCK_ENV_ACCESS_IN_NODE to true by default, so any Code node that read process.env silently loses access until you set it back to false; the same release began enforcing strict permissions on the settings file. Read the 2.0 breaking-changes page before crossing a major boundary. n8n runs any needed database migrations automatically on start — that is exactly why the pre-upgrade pg_dump is not optional. Because credentials live encrypted with a key in .env and the data lives in Postgres, the containers are disposable: you upgrade by replacing them, and roll back by pinning the previous tag and restoring the dump.

Failure modes, with the strings you will see

The requested webhook "POST hello" is not registered. A 404 from calling a webhook whose workflow is not Active, or from calling the test path when nobody is listening. Test paths (/webhook-test/...) answer only while you have clicked "Listen for test event"; production paths (/webhook/...) answer only when the workflow toggle is on. The sibling This webhook is not registered for GET requests. Did you mean to make a POST request? means the method is wrong — the node expects POST and you sent GET.

The webhook URL shows a :5678 or localhost. The node displays https://n8n.example.com:5678/webhook/... or http://localhost:5678/.... WEBHOOK_URL is unset or wrong, so n8n built 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 disappears.

There was a problem loading init data in the browser. The editor loaded but cannot reach its own backend API. Behind a proxy this is almost always a wrong N8N_HOST or WEBHOOK_URL, a proxy missing the WebSocket Upgrade headers, or N8N_PROTOCOL not matching how you connect. Confirm the four public-facing variables and that the proxy forwards Upgrade and Connection.

password authentication failed for user "n8n" in the logs, with the container restarting. The password n8n sends does not match what the database was initialised with. The trap: Postgres reads POSTGRES_PASSWORD only when it initialises an empty data directory. Start the stack once, then change POSTGRES_PASSWORD in .env, and the existing postgres_data volume still holds the old password. Set it back to the original, or, if you have no data to keep, docker compose down and docker volume rm the postgres volume, then bring it up fresh.

EACCES: permission denied, open '/home/node/.n8n/config' on start. n8n runs as the node user (UID 1000) and cannot write its config directory. This bites people who bind-mount a host folder (./n8n_data:/home/node/.n8n) owned by root. Use the named volume shown above, or if you insist on a 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 enforces 0600 on that settings file by default and fixes it itself on boot — this log line means it already corrected the mode, commonly after a bind mount or after a restore copied the file back with loose permissions. No action is needed; set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false only if your filesystem genuinely cannot support permissions.

Mismatching encryption keys — the fuller line says the encryption key in the settings file /home/node/.n8n/config does not match the N8N_ENCRYPTION_KEY in your environment. The key in your environment differs from the one n8n wrote into its data volume on a previous run — most often because n8n generated a random key on an earlier boot when the variable was unset, and you then set a different one. Put the original key back in .env, or, only if you truly have no stored credentials worth keeping, delete the config file inside the n8n_data volume and let n8n regenerate it — accepting that existing credentials 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 reached n8n over plain HTTP — usually by hitting the IP and port directly instead of the HTTPS proxy. Reach it via https://n8n.example.com/. Only if you genuinely cannot use HTTPS should you set N8N_SECURE_COOKIE=false, and never on an internet-facing box.

FAQ

Should I use SQLite or Postgres for n8n?

SQLite (the default) is fine for trying n8n out and for a personal instance running one workflow at a time. Move to Postgres for anything you depend on: SQLite's single writer lock throws database is locked under concurrency, and Postgres backs up cleanly with pg_dump. Migrating later is manual, so if the box matters, start on Postgres.

Why do my n8n webhooks never fire?

Almost always WEBHOOK_URL. Unset or wrong, n8n prints webhook addresses built from N8N_HOST:N8N_PORT — often with a :5678 or localhost in them — that look valid but are unreachable from the internet, so the caller's requests never arrive. Set WEBHOOK_URL=https://n8n.example.com/ and confirm the node shows a URL with no port. The second cause is calling a webhook whose workflow is not toggled Active, which returns The requested webhook ... is not registered.

What must I back up in n8n?

Two things. The N8N_ENCRYPTION_KEY from your .env file, because every stored credential is encrypted with it and losing it makes them permanently undecryptable — copy it off the server the day you create it. And a pg_dump of the Postgres database for the workflows, history and credentials. A restore needs both: the same key plus the dump.

How do I put n8n behind HTTPS?

n8n serves plain HTTP on port 5678; a reverse proxy in front terminates TLS. Bind n8n to 127.0.0.1:5678 so only the proxy can reach it, 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 or the editor hangs.

How do I upgrade n8n safely?

Pin a specific image tag instead of latest, take a pg_dump first because n8n runs migrations automatically on 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 is disposable, so roll back by pinning the previous tag and restoring the pre-upgrade dump.