Back to Blog
Tutorial6 min read2026-07-12

How to Deploy a Self-Hosted n8n Instance with PostgreSQL

Run n8n in production: Postgres instead of SQLite, a stable encryption key, correct webhook URLs, and a Git-push deploy that survives restarts.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

n8n is one of those tools that starts as a weekend experiment and ends up running half your company's glue logic. Which is exactly why the default setup — SQLite on a local disk, an auto-generated encryption key — is dangerous the moment it matters. If your container restarts and the filesystem is gone, so are your workflows and every saved credential.

Here's how to deploy n8n properly: Postgres as the database, a pinned encryption key, webhook URLs that actually resolve, and the whole thing shipping on git push.

The three things that break self-hosted n8n

Before any commands, know what you're defending against:

  1. 1SQLite by default. n8n stores workflows, credentials, and execution history in ~/.n8n/database.sqlite unless you tell it otherwise. On any platform with ephemeral container filesystems, that file disappears on every redeploy.
  2. 2The auto-generated encryption key. n8n encrypts saved credentials with a key it writes to ~/.n8n/config on first boot. New container, new key — and every credential you saved becomes undecryptable. You'll see Credentials could not be decrypted errors and have to re-enter everything.
  3. 3Wrong webhook URLs. Webhook trigger nodes register URLs based on what n8n thinks its public address is. Behind a proxy, it guesses wrong unless you set WEBHOOK_URL explicitly.

All three are fixed with environment variables. None are fixed by hoping.

Postgres, not SQLite

n8n reads its database config from discrete DB_POSTGRESDB_* variables:

DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=your-db-host
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=n8n
DB_POSTGRESDB_PASSWORD=...
DB_POSTGRESDB_SCHEMA=public

Most managed platforms hand you a single DATABASE_URL connection string instead. n8n does not parse DATABASE_URL natively, so you bridge the two with a small entrypoint wrapper. The official image ships Node, so you can parse the URL robustly instead of fighting sed:

#!/bin/sh
# docker-entrypoint-wrapper.sh
if [ -n "$DATABASE_URL" ]; then
  export DB_TYPE=postgresdb
  eval "$(node -e '
    const u = new URL(process.env.DATABASE_URL);
    console.log(`export DB_POSTGRESDB_HOST=${u.hostname}`);
    console.log(`export DB_POSTGRESDB_PORT=${u.port || 5432}`);
    console.log(`export DB_POSTGRESDB_DATABASE=${u.pathname.slice(1)}`);
    console.log(`export DB_POSTGRESDB_USER=${decodeURIComponent(u.username)}`);
    console.log(`export DB_POSTGRESDB_PASSWORD='"'"'${decodeURIComponent(u.password)}'"'"'`);
  ')"
fi
exec /docker-entrypoint.sh "$@"

The single-quoting around the password matters — generated passwords love characters that shells love to mangle.

The Dockerfile

Build on the official image and swap in the wrapper:

FROM docker.n8n.io/n8nio/n8n:latest

USER root
COPY docker-entrypoint-wrapper.sh /docker-entrypoint-wrapper.sh
RUN chmod +x /docker-entrypoint-wrapper.sh
USER node

EXPOSE 5678
ENTRYPOINT ["/docker-entrypoint-wrapper.sh"]

n8n listens on port 5678 by default (N8N_PORT if you need to change it). Keep the USER node line — the official image runs unprivileged, and you should keep it that way.

Pin a specific tag (n8nio/n8n:1.x.y) in production rather than latest. n8n releases frequently, and its startup migrations are forward-only — you don't want a surprise major upgrade mid-redeploy.

The environment variables that matter

# Non-negotiable: pin this before your first real credential
N8N_ENCRYPTION_KEY=<output of: openssl rand -hex 32>

# Public URL — webhooks register against this
WEBHOOK_URL=https://n8n.yourdomain.com/
N8N_PROTOCOL=https
N8N_HOST=n8n.yourdomain.com

# Keep execution history from eating your database
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=168   # hours; one week

# So cron-based workflows fire when you expect
GENERIC_TIMEZONE=Asia/Kolkata

Two of these deserve emphasis.

N8N_ENCRYPTION_KEY: set it before you save your first credential, and never change it afterward. If you already have an instance running with an auto-generated key, copy the key out of ~/.n8n/config inside the running container and set it as the env var — that migrates you safely.

Execution pruning: every workflow run writes execution data to Postgres. A busy instance without pruning will happily grow its database by gigabytes a month, most of it JSON blobs of intermediate node data you'll never look at. Prune aggressively; you can always increase retention later.

Migrations: n8n handles them, but read this anyway

n8n runs its own schema migrations automatically at startup — there's no separate migrate command to wire up. The gotcha is concurrency: run one instance while a version upgrade boots, because two replicas racing the same migration is a bad afternoon. If you eventually need horizontal scaling, n8n has a dedicated queue mode backed by Redis with separate webhook and worker processes — that's a different architecture and a different post.

For the common case — one instance, Postgres, modest workflow volume — a single container is the right call, not a compromise.

Deploying on PandaStack

This is the part where a platform either helps or makes you do plumbing. On PandaStack:

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x) from the dashboard. It's KubeBlocks-orchestrated under the hood, with daily backups retained per plan.
  2. 2Connect your repo — the one containing the Dockerfile and wrapper script above — as a container app. PandaStack detects the Dockerfile and builds it with rootless BuildKit in an ephemeral build pod; you watch the build logs stream live.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically — which is exactly what the entrypoint wrapper is waiting for. No copying credentials between tabs.
  4. 4Set the remaining env vars (N8N_ENCRYPTION_KEY, WEBHOOK_URL, pruning, timezone) in the app's environment settings.
  5. 5Push. Every subsequent git push rebuilds and redeploys. Custom domain and SSL are handled by the platform, so WEBHOOK_URL can point at your real domain from day one.

One honest caveat about the free tier

PandaStack's free tier scales idle apps to zero. That's great for a demo, and wrong for a production n8n instance: schedule and cron trigger nodes only fire while the process is running, and inbound webhooks would eat a cold start. Webhook-only workflows will still work — the request wakes the app — but anything time-based needs the process alive. For real automation, run n8n on a paid always-on tier; n8n is lightweight enough that a small instance handles a lot of workflows.

Verifying the deploy

After the first boot, check three things:

# 1. The app database is Postgres, not SQLite —
#    in the n8n UI: Settings → check version/db, or query your DB:
psql "$DATABASE_URL" -c "\dt" | grep workflow_entity

# 2. Credentials survive a restart: save a test credential,
#    trigger a redeploy, confirm it still decrypts.

# 3. Webhook URLs are public: create a Webhook node and
#    confirm the displayed URL matches your domain, then:
curl -X POST https://n8n.yourdomain.com/webhook-test/<path>

Step 2 is the one people skip. It takes two minutes and it's the difference between finding an encryption-key mistake today versus finding it in three months with forty saved credentials.

Once those pass, you have an n8n instance that treats restarts as a non-event — workflows in Postgres, credentials decryptable, webhooks pointing at the right place. If you want the database provisioning and wiring handled for you, it's a short path on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also