Operations

Self-Hosting n8n for Workflow Automation on a VPS

n8n is a source-available workflow automation engine that you run yourself, which makes it a natural fit for a VPS. The managed n8n Cloud plans meter usage by workflow executions, so a busy automation setup accrues a recurring bill that grows with volume. Running the same engine on a VPS with Docker gives you a fixed monthly cost regardless of how many workflows fire, plus direct control over data residency, network access, and version pinning. This guide walks through a production deployment: Docker Compose with PostgreSQL, correct environment and webhook configuration behind a reverse proxy for HTTPS, persistence, baseline security, and backups. It assumes you have a domain pointed at the server and Docker with the Compose plugin installed.

Why self-host instead of paying per execution

The decision comes down to cost predictability and control. n8n Cloud bills against an execution quota, which is fine for light use but scales unfavorably when you run polling triggers, high-frequency schedules, or fan-out workflows that call many nodes. Self-hosting on a VPS removes the per-execution cloud fee and replaces it with the flat cost of the server. Beyond price, self-hosting gives you:

  • Data control. Credentials and execution data stay on infrastructure you own, which matters for compliance and for workflows that touch internal systems.
  • Network reach. The instance can talk to private services on the same VPS or VPC without exposing them publicly.
  • Version pinning. You choose when to upgrade rather than being moved automatically.

The tradeoff is that you own operations: updates, backups, and security are your responsibility. The rest of this guide covers the parts that are easy to get wrong.

Use PostgreSQL, not SQLite

n8n defaults to SQLite, which is convenient for a quick test but is not appropriate for production. SQLite serializes writes and does not handle concurrent execution data or larger workloads gracefully. For any real deployment, back n8n with PostgreSQL. The official Docker Compose example uses postgres:16 and connects n8n with the DB_TYPE=postgresdb family of variables. Switching the database later means migrating data, so start on Postgres from day one.

Docker Compose deployment

Create a project directory and two files. First, an .env file for secrets and configuration. Keep it out of version control.

# .env
# Postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change_this_root_password
POSTGRES_DB=n8n
POSTGRES_NON_ROOT_USER=n8n
POSTGRES_NON_ROOT_PASSWORD=change_this_app_password

# n8n
N8N_HOST=n8n.example.com
N8N_PROTOCOL=https
N8N_PORT=5678
WEBHOOK_URL=https://n8n.example.com/
N8N_PROXY_HOPS=1
GENERIC_TIMEZONE=Asia/Ho_Chi_Minh

# Generate with: openssl rand -hex 32
N8N_ENCRYPTION_KEY=replace_with_a_long_random_string

Then the compose.yaml. This runs Postgres and n8n on an internal network. n8n listens on 5678, published only to localhost so the reverse proxy terminates TLS in front of it.

# compose.yaml
services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      - POSTGRES_USER
      - POSTGRES_PASSWORD
      - POSTGRES_DB
      - POSTGRES_NON_ROOT_USER
      - POSTGRES_NON_ROOT_PASSWORD
    volumes:
      - db_storage:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
      interval: 10s
      timeout: 5s
      retries: 10

  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: unless-stopped
    ports:
      - '127.0.0.1:5678:5678'
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
      - DB_POSTGRESDB_USER=${POSTGRES_NON_ROOT_USER}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_NON_ROOT_PASSWORD}
      - N8N_HOST=${N8N_HOST}
      - N8N_PORT=${N8N_PORT}
      - N8N_PROTOCOL=${N8N_PROTOCOL}
      - WEBHOOK_URL=${WEBHOOK_URL}
      - N8N_PROXY_HOPS=${N8N_PROXY_HOPS}
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
    volumes:
      - n8n_storage:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  db_storage:
  n8n_storage:

Bring the stack up and watch the logs until n8n reports it is ready:

docker compose up -d
docker compose logs -f n8n

The $${POSTGRES_USER} double dollar in the healthcheck escapes the variable so Compose passes it through to the container shell rather than interpolating it at parse time.

Webhook URL and reverse proxy for HTTPS

This is the step most self-hosted setups get wrong. n8n normally builds webhook URLs from N8N_PROTOCOL, N8N_HOST, and N8N_PORT. Behind a reverse proxy that does not work, because n8n runs internally on 5678 while the proxy exposes it on 443. You must set WEBHOOK_URL to the full public HTTPS address, including the scheme and the trailing slash, so webhook nodes register the correct external address with third-party services. Also set N8N_PROXY_HOPS to 1 so n8n trusts the forwarding headers from your single proxy.

Your proxy must forward the standard headers X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto. Caddy sets these automatically and provisions TLS certificates for you. A minimal Caddyfile:

n8n.example.com {
    reverse_proxy 127.0.0.1:5678
}

With nginx you would proxy to 127.0.0.1:5678, add proxy_set_header lines for the forwarded headers, and terminate TLS with a certificate from a provider such as Let's Encrypt. Either way, the public entry point is HTTPS on 443 and n8n itself is never exposed directly. See the official reference at docs.n8n.io.

Persistence and the encryption key

Two pieces of state must survive container restarts and upgrades. The PostgreSQL data lives in the db_storage volume, and n8n's own data directory, /home/node/.n8n, lives in n8n_storage. That directory holds the encryption key n8n uses to encrypt stored credentials.

Set N8N_ENCRYPTION_KEY explicitly and record it somewhere safe. If n8n generates its own key and you lose the .n8n volume, every saved credential becomes unrecoverable. Pinning the key in the environment also lets you restore a database backup onto a fresh instance and still decrypt credentials.

Generate a strong key once with openssl rand -hex 32 and keep it in your .env and your password manager.

Baseline security

A public automation tool that can reach your internal systems deserves careful hardening. The essentials:

  • Never expose 5678 publicly. Publish it to 127.0.0.1 only, as in the Compose file, and let the reverse proxy be the sole public port.
  • Enforce HTTPS at the proxy and let it redirect plain HTTP.
  • Use a firewall. Allow only 22, 80, and 443 inbound; block direct database access from the internet.
  • Use file-based secrets when you can. n8n supports appending _FILE to variable names such as DB_POSTGRESDB_PASSWORD_FILE to read a value from a mounted file instead of an environment string.
  • Keep n8n updated. Pull the new image, then recreate the container. Review release notes before major version jumps.
docker compose pull
docker compose up -d

Backups

A working deployment is not safe until you can restore it. Back up two things: the Postgres database and the n8n encryption key. Dump the database on a schedule with pg_dump running inside the Postgres container:

docker compose exec -T postgres \
  pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
  | gzip > backup-$(date +%F).sql.gz

Run that from a cron job, copy the output off the VPS to separate storage, and keep the N8N_ENCRYPTION_KEY alongside it. To restore, start a fresh stack with the same encryption key, then pipe the dump back into psql. Because credentials are encrypted with that key, a database restore without it leaves your credentials unreadable. Test a restore at least once so you know the procedure works before you need it.

Wrap-up

Self-hosting n8n on a VPS is a small, well-understood stack: PostgreSQL for durable state, a single n8n container, and a reverse proxy for TLS. Get three things right and the rest is routine. Use Postgres from the start, set WEBHOOK_URL and N8N_PROXY_HOPS correctly behind the proxy, and pin and back up N8N_ENCRYPTION_KEY so credentials survive a rebuild. For the full set of configuration options, consult the official documentation at docs.n8n.io.