# n8n Docker Compose with PostgreSQL: The Settings That Matter

> A working n8n Docker Compose file with PostgreSQL, plus what the quickstart skips: encryption key, webhook URL, proxy hops, timezone and a pinned version.

1. [Home](/)
2. [Blog](/blog)
3. n8n Docker Compose with PostgreSQL: The Settings That Matter

DeploymentOctober 24, 202610 min read

# n8n Docker Compose with PostgreSQL: The Settings That Matter

A working n8n Docker Compose file with PostgreSQL, and the settings the quickstart leaves out: the encryption key, the webhook URL, the proxy hop count, the timezone and a pinned version. What breaks without each one, how to update without losing credentials, and what to back up.

## The short version

A production n8n Docker Compose setup is two services, n8n and PostgreSQL, plus a handful of environment variables the quickstart leaves out. Set N8N\_ENCRYPTION\_KEY yourself and keep a copy, because every saved credential is encrypted with it. Set N8N\_WEBHOOK\_URL (WEBHOOK\_URL in older guides) to your public HTTPS address, or webhooks point at localhost:5678\. Set N8N\_PROXY\_HOPS=1 behind a reverse proxy, GENERIC\_TIMEZONE so schedules fire at the right hour, and pin the image to a version instead of latest. Back up the database dump and the key together; one without the other does not restore your credentials.

[Teo Marquardt](/about#author)· Facts last checked October 24, 2026

Most n8n Docker Compose guides start from the quickstart: one `docker run` line, and a minute later the editor is open on `localhost:5678`. That instance is fine for trying things. It keeps everything in a SQLite file inside the container's data directory, it generated its own encryption key on first start, and it believes its public address is `localhost`. On a laptop none of that matters. On a server it starts to matter the day you connect a webhook or recreate the container.

Below is a Docker Compose file for running n8n with PostgreSQL on a server, and the five settings around it that decide whether the instance survives its first update: the encryption key, the webhook URL, the proxy hop count, the timezone and the image version. Each section says what breaks without the setting, quoting the error you'll see. Versions and defaults are as of writing, with n8n 2.44 as the current stable release.

## A working n8n Docker Compose file with PostgreSQL

Two files in one directory. The `.env` file holds the values that differ per server, and Compose reads it automatically:

bash

```
# .env
N8N_VERSION=2.44.6
N8N_DOMAIN=n8n.example.eu
TIMEZONE=Europe/Berlin

POSTGRES_PASSWORD=change-me-to-a-long-random-string
# Generate once with: openssl rand -base64 24
# Then never change it. See the section on the encryption key.
N8N_ENCRYPTION_KEY=paste-the-generated-value-here
```

yaml

```
# docker-compose.yml
services:
  postgres:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: n8n
      # postgres:18 moved its default data directory; pinning it
      # keeps the volume mount the same across major versions.
      PGDATA: /var/lib/postgresql/data
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
      interval: 5s
      timeout: 5s
      retries: 10

  n8n:
    image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
    restart: unless-stopped
    ports:
      - "127.0.0.1:5678:5678"   # only the reverse proxy on this host reaches it
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_HOST: ${N8N_DOMAIN}
      N8N_PROTOCOL: https
      N8N_EDITOR_BASE_URL: https://${N8N_DOMAIN}/
      N8N_WEBHOOK_URL: https://${N8N_DOMAIN}/
      N8N_PROXY_HOPS: 1
      GENERIC_TIMEZONE: ${TIMEZONE}
      TZ: ${TIMEZONE}
      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  db_data:
  n8n_data:
```

`docker compose up -d` starts both. The `depends_on` condition makes n8n wait until `pg_isready` succeeds, so the first start doesn't fail on a database that is still initialising. The port is bound to `127.0.0.1`, which means n8n is reachable only from the server itself, and the public HTTPS address belongs to a reverse proxy in front of it. If you publish `5678:5678` instead, the editor is open to the internet over plain HTTP.

The official [n8n-hosting repository](https://github.com/n8n-io/n8n-hosting) has a longer version of this file. It creates a separate non-root database user through an init script and adds a second container, `n8nio/runners`, which runs Code node tasks in external mode. Both are worth adopting later. The external runner is also what you need for Python in the Code node, since the main n8n image does not ship Python. The file above is the smallest setup that is safe to leave running.

### Why PostgreSQL and not the default SQLite

Without `DB_TYPE`, n8n stores workflows, credentials and execution history in `database.sqlite` inside `/home/node/.n8n`. That works for one person testing. It gets uncomfortable on a busy instance, because execution history grows fast and every execution writes to one file. It also turns backup into copying a file that may be mid-write. With PostgreSQL the data lives in a real database you can dump while n8n runs, restore elsewhere, and later move to [managed PostgreSQL](/services/postgresql) without touching n8n beyond five variables.

|                          | SQLite (default)                                       | PostgreSQL                                          |
| ------------------------ | ------------------------------------------------------ | --------------------------------------------------- |
| Where the data lives     | /home/node/.n8n/database.sqlite, inside the n8n volume | A separate database service or server               |
| Backup while running     | Copy a live file, or stop n8n first                    | pg\_dump at any time                                |
| Queue mode with workers  | Not recommended by n8n                                 | The recommended database                            |
| Moving to another server | Copy the volume                                        | Dump and restore, or point n8n at the same database |

### Why the n8n volume still matters with Postgres

Switching to PostgreSQL does not make `/home/node/.n8n` disposable. n8n's own documentation recommends keeping it as a volume. It holds the settings file, and unless you set the key yourself, that file holds the only copy of your encryption key. Lose the volume and you keep every workflow but lose access to every credential.

## N8N\_ENCRYPTION\_KEY: the one value you cannot lose

n8n encrypts every saved credential (API keys, OAuth tokens, database passwords) before writing it to the database. If `N8N_ENCRYPTION_KEY` is not set, n8n generates a random key on first start and writes it to `/home/node/.n8n/config`. From then on, the database is only half of your data. The other half is that small file, and it lives wherever the volume lives.

If the key goes missing, because the volume was deleted, the container was recreated without one, or the database was restored onto a fresh instance, workflows still load and run, right up to the first node that needs a credential. Then the execution fails with this:

text

```
Credentials could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.
```

There is no recovery from the encrypted data alone. Either you find the old key, or you re-enter every API key and reconnect every OAuth account by hand. Set the key yourself in `.env` and keep a copy of that file somewhere other than the server, and a lost volume costs you nothing. n8n's own generator uses 24 random bytes in base64, so `openssl rand -base64 24` gives you a key of the same shape:

bash

```
openssl rand -base64 24
# 3q2+7w8kRr0cX1pK9vZ2mN4tYb6hLs5F
```

### Mismatching encryption keys

The other way to meet this problem is to do the right thing late. You have an instance that has been running on a generated key, you add `N8N_ENCRYPTION_KEY` with a fresh value, and n8n refuses to start:

text

```
Mismatching encryption keys. The encryption key in the settings file /home/node/.n8n/config does not match the N8N_ENCRYPTION_KEY env var. Please make sure both keys match.
```

This is n8n protecting you: the credentials in the database were encrypted with the key in the settings file, and starting with a different one would make them all unreadable. The fix is to adopt the existing key instead of inventing a new one. Read it from the running container and put that exact value into `.env`:

bash

```
docker compose exec n8n cat /home/node/.n8n/config
# {
#   "encryptionKey": "9xQp...the-key-n8n-generated..."
# }
```

While you're in that directory: the settings file holds a secret, so n8n wants it readable by its owner only, and the official Docker instructions set `N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true` for that reason. If the file's permissions are looser, n8n fixes them itself and logs one line at error level: `Permissions 0644 for n8n settings file /home/node/.n8n/config are too wide. Changing permissions to 0600..`. Despite the error level, that's the check working.

## N8N\_WEBHOOK\_URL: why your webhooks say localhost:5678

n8n builds every URL it shows you, from the Webhook node's production and test URLs to the OAuth callback URL you paste into Google or GitHub, from what it knows about its own address. Behind a reverse proxy, n8n only knows that it listens on port 5678 inside a container. So the Webhook node shows something like this, and any external service you give it to will fail to connect:

text

```
http://localhost:5678/webhook/3f6c1a2e-8b1d-4c7e-9a55-0d2b7e4f9c11
```

Setting `N8N_WEBHOOK_URL` to the public base address fixes the webhook URLs. `N8N_EDITOR_BASE_URL` does the same for links in the editor and in emails n8n sends, and `N8N_HOST` with `N8N_PROTOCOL` fill in the rest. Most guides, and many existing setups, use the older `WEBHOOK_URL` variable (the full list is in n8n's [environment variables reference](https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables)). Current 2.x releases log it as deprecated and point to `N8N_WEBHOOK_URL`, which sets the base URL for test and production webhooks alike. If you are upgrading an older instance, rename the variable and keep the value.

After changing the URL, recreate the container (`docker compose up -d` does that when the environment changed) and update every external service that already calls your webhooks. The paths stay the same, only the host changes. A GitHub webhook that points at your n8n instance also has to answer within GitHub's timeout, which is its own problem and covered in [why GitHub webhooks time out](/blog/github-webhook-timed-out).

## Behind a reverse proxy: N8N\_PROXY\_HOPS

A reverse proxy adds an `X-Forwarded-For` header with the client's real IP. n8n's HTTP server ignores that header unless it is told how many proxies sit in front of it, and its rate limiter notices the mismatch. The log fills with this:

text

```
ValidationError: The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is false (default). This could indicate a misconfiguration which would prevent express-rate-limit from accurately identifying users.
```

The fix is `N8N_PROXY_HOPS`, set to the number of proxies between the internet and n8n. One reverse proxy on the same host is `1`. A CDN in front of that proxy makes it `2`. The default is `0`, which is correct only when nothing sits in front of n8n.

### n8n Docker Compose without Traefik

Many n8n Compose examples ship with Traefik, so it can look like part of n8n. It isn't. Any reverse proxy that terminates HTTPS and forwards to `127.0.0.1:5678` works: Caddy, nginx, or the proxy your platform already runs. Two things to get right in whichever you use. Forward the `Host` and `X-Forwarded-Proto` headers. Allow WebSocket upgrades, because the editor uses a persistent connection to push execution updates, and without it the canvas loads but never shows a run finishing. With Caddy, the whole configuration is two lines, since it handles certificates, headers and WebSockets by default:

text

```
n8n.example.eu {
    reverse_proxy 127.0.0.1:5678
}
```

With nginx, add `proxy_http_version 1.1`, `proxy_set_header Upgrade $http_upgrade` and `proxy_set_header Connection "upgrade"` to the location block. If nginx answers with a 502 instead of the editor, the cause is almost always that it can't reach port 5678, and [the line in its error log says which way](/blog/502-bad-gateway-nginx).

## GENERIC\_TIMEZONE: schedules that fire at the wrong hour

A Schedule Trigger set to 09:00 runs at 09:00 in the instance's timezone, and n8n's default is `America/New_York`. A server in Germany running a fresh n8n will send the morning report at three in the afternoon. Two variables cover it. `GENERIC_TIMEZONE` is what n8n uses for schedules and date nodes. `TZ` is the container's system timezone, which shows up in logs and in anything a Code node does with the system clock. Set both to the same value. A single workflow can still override the timezone in its own settings.

| Variable                 | Without it                                                                           | Example                        |
| ------------------------ | ------------------------------------------------------------------------------------ | ------------------------------ |
| N8N\_ENCRYPTION\_KEY     | A generated key lives only in the volume; lose it and credentials can't be decrypted | openssl rand -base64 24        |
| N8N\_WEBHOOK\_URL        | Webhook and OAuth URLs show http://localhost:5678/                                   | https://n8n.example.eu/        |
| N8N\_PROXY\_HOPS         | X-Forwarded-For validation errors in the log behind a proxy                          | 1                              |
| GENERIC\_TIMEZONE and TZ | Schedules run on New York time                                                       | Europe/Berlin                  |
| A pinned image tag       | latest upgrades whenever the image is pulled                                         | docker.n8n.io/n8nio/n8n:2.44.6 |

## Update n8n in Docker Compose without losing data

If the Compose file says `n8nio/n8n:latest`, an update happens whenever someone pulls, including by accident on a reboot script, and you find out which version you're on from a changelog after something broke. Pin the tag in `.env` and an update becomes a deliberate one-line change you can roll back.

n8n runs database migrations when a new version starts. Starting an older image afterwards does not reliably undo them, so the database dump you take before the update is your rollback. The order that keeps you safe:

bash

```
# 1. Dump the database while the old version still runs
docker compose exec -T postgres pg_dump -U n8n -Fc n8n > n8n-$(date +%F).dump

# 2. Change the pinned version in .env, e.g. N8N_VERSION=2.45.0

# 3. Pull the new image and recreate the container
docker compose pull && docker compose up -d

# 4. Watch the migrations and the first start
docker compose logs -f n8n
```

If the new version misbehaves, set the old tag back, restore the dump into an empty database with `pg_restore` and start again. Read the release notes before a major-version jump, because n8n lists breaking changes there, and skipping several majors at once multiplies what can go wrong.

## What to back up: the database and the key

With PostgreSQL, an n8n backup has two parts, and each is useless without the other. The database dump holds workflows, credentials (encrypted), users and execution history. The encryption key, in `.env` or in `/home/node/.n8n/config`, is what makes those credentials readable. A dump restored on a new server with a new key gives you all your workflows and none of your credentials.

Back up the key together with the dump

Store a copy of the encryption key wherever your database dumps go, encrypted at rest, and test a restore on a second instance once. It is the only way to find out the key you saved is the right one.

The dump itself is ordinary PostgreSQL work: `pg_dump` on a schedule, copied off the server. [Sending PostgreSQL backups to S3-compatible storage](/blog/postgresql-backup-to-s3) covers that part, including retention. As a second, portable layer, the n8n CLI can export workflows and credentials to JSON. Credentials stay encrypted in that export unless you add `--decrypted`, in which case the file contains plaintext secrets and needs the same care as the key:

bash

```
docker compose exec n8n n8n export:workflow --all --output=/home/node/.n8n/backup/workflows.json
docker compose exec n8n n8n export:credentials --all --output=/home/node/.n8n/backup/credentials.json
```

The export is also how you move from an old SQLite instance to PostgreSQL: export from the old one, start the new one with the same encryption key, and import with `n8n import:workflow` and `n8n import:credentials`.

## When one container is not enough: queue mode

The setup above runs n8n in regular mode: one process receives webhooks, runs the editor and executes every workflow. That carries a lot further than people expect. When it stops keeping up, give it more memory and CPU first. A heavy workflow that runs out of memory gets the container killed, and [exit code 137](/blog/exit-code-137) in `docker compose ps` is how that shows up. Queue mode is the next step: `EXECUTIONS_MODE=queue`, a Redis service, and one or more `worker` containers that pick executions off the queue. n8n's [queue mode documentation](https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/enable-queue-mode) recommends PostgreSQL for it, which is one more reason to start there. It also adds three moving parts to monitor, so it's worth it once a single larger instance is the bottleneck, and not before.

## How Runsite handles it

If you'd rather not run the server, the proxy and the update routine yourself, [the n8n template on Runsite](/templates/n8n) deploys this same shape in one click: the official n8n image behind HTTPS on its own URL, with its own PostgreSQL database, on servers in Germany. The template copies a repository into your GitHub account and pins the n8n version there, so an update is a one-line commit you can revert. It refuses to start without `N8N_ENCRYPTION_KEY`, so n8n never invents a key that the next redeploy throws away. It sets the webhook URL, editor URL, host and protocol from a single `PUBLIC_URL`, and on the first deploy it waits for the database instead of crashing while it boots.

It runs n8n in regular mode, without queue mode workers or Redis, and the official image it uses has no Python. The repository includes a `docker-compose.yml` with PostgreSQL, so the same setup also runs on your laptop or on any server. If the next thing you self-host is a password manager, the [Vaultwarden Docker Compose guide](/blog/vaultwarden-docker-compose) follows the same format.

## The short version

* Run n8n with PostgreSQL, not the default SQLite, and keep the `/home/node/.n8n` volume anyway.
* Set `N8N_ENCRYPTION_KEY` yourself and keep a copy off the server. Without it, `Credentials could not be decrypted` is one lost volume away. On an existing instance, copy the key from `/home/node/.n8n/config` instead of making a new one.
* Set `N8N_WEBHOOK_URL` and `N8N_EDITOR_BASE_URL` to your public HTTPS address, or webhooks and OAuth callbacks point at `localhost:5678`.
* Behind a reverse proxy, set `N8N_PROXY_HOPS=1`, forward the host and protocol headers, and allow WebSocket upgrades.
* Set `GENERIC_TIMEZONE` and `TZ`, pin the image tag, and update in the order dump, pull, recreate. The dump and the key together are your backup.

Related service

## n8n template on Runsite

Deploy from a single git push and keep active apps warm — no cold starts, hosted entirely in the EU with a signed GDPR DPA on every plan.

[Explore n8n template](/templates/n8n)

[Back to all articles](/blog)

FAQ

## Frequently Asked Questions

Common questions about this service.

### Where does n8n store the encryption key in Docker?

If N8N\_ENCRYPTION\_KEY is not set, n8n generates a key on first start and saves it in the settings file at /home/node/.n8n/config inside the container, which normally sits on a Docker volume. You can read it with docker compose exec n8n cat /home/node/.n8n/config. If you set N8N\_ENCRYPTION\_KEY, n8n uses that value, and it must match the key in the settings file or n8n refuses to start with a Mismatching encryption keys error.

### Why does my n8n webhook URL show localhost?

Because n8n builds webhook URLs from its own address, and behind a reverse proxy it only knows it listens on port 5678\. Set N8N\_WEBHOOK\_URL (WEBHOOK\_URL in older versions and guides) to your public base address, for example https://n8n.example.eu/, set N8N\_EDITOR\_BASE\_URL to the same value, and recreate the container. The Webhook node then shows URLs on your domain.

### How do I update n8n in Docker Compose?

Pin the image to a version, dump the database while the old version runs, change the version in your .env file, then run docker compose pull and docker compose up -d and watch the logs while migrations run. Keep the dump until the new version has run for a while: n8n migrates the database on start, so going back means restoring the dump and starting the old tag.

### Do I need PostgreSQL for n8n, or is SQLite enough?

SQLite is the default and is enough for trying n8n or a light personal instance. For anything that runs unattended, PostgreSQL is the better choice: you can back it up with pg\_dump while n8n runs, move it to another server or a managed database, and it is required for queue mode with workers. Keep the /home/node/.n8n volume either way, because it holds the settings file.

Keep reading

## Related articles

[Deployment9 min readGitHub Webhook "Timed out": Why Your Host Drops DeliveriesGitHub gives your endpoint 10 seconds and never retries on its own. Where those seconds go on a sleeping free tier, a serverless function or a deploy, and how to build a receiver that doesn't lose events.Sep 29, 2026Read](/blog/github-webhook-timed-out)[Databases7 min readHow to Automate PostgreSQL Backups to S3 on a ScheduleA managed database backs itself up daily — but a second, portable copy in your own S3 bucket is yours to keep. Here's how to schedule pg\_dump to object storage with a cron job, and when it's worth doing.Jun 22, 2026Read](/blog/postgresql-backup-to-s3)[Deployment9 min read502 Bad Gateway in Nginx After Deploy: Read the Error LogA 502 Bad Gateway page from nginx says nothing about the cause. The matching line in error.log does: connection refused, a worker that died mid-response, a keep-alive reset or an oversized header. What each one means and how to fix it after a deploy.Oct 9, 2026Read](/blog/502-bad-gateway-nginx)

## Your app deserves to be online

€5 of credit on signup. Deploy in under a minute. No credit card needed.

[Start deploying](https://dashboard.runsite.app/login)[View documentation](https://docs.runsite.app)

---

Source: https://runsite.app/blog/n8n-docker-compose
