We raised a Series A! Read a post from our CEO, Zhen Lu: 1M devs and the cloud we're building next.

How to Self-Host n8n on Runpod: Persistent Storage, HTTP Proxy, and AI-Ready Workflows

Self-hosting n8n on Runpod removes two of the more tedious parts of a standard deployment: HTTPS configuration and persistent storage setup. Runpod’s built-in HTTP proxy handles HTTPS termination automatically when you expose the pod’s HTTP port at https://[POD_ID]-5678.proxy.runpod.net, and its NVMe-backed Network Volumes keep your n8n data (workflows, credentials, and execution history) on storage that persists independently of the pod lifecycle. No Nginx, no Certbot, no always-on VPS (virtual private server) required.

Most n8n self-hosting guides assume you are deploying to a generic Linux VPS. They walk you through reverse proxy configuration, Let’s Encrypt certificate renewal, and Docker Compose networking, all before n8n processes a single workflow. If your inference endpoints already run on Runpod, that overhead can largely be avoided.

This guide covers Runpod-specific steps that generic guides omit: Network Volume creation, pod deployment, HTTP proxy exposure, and PostgreSQL setup, all on the same platform as your AI workloads. Runpod handles the two things that consume most setup time in a generic guide, HTTPS and persistent storage, and everything else is standard Docker container configuration.

Why Run n8n on Runpod Rather Than a Separate VPS

Run n8n on Runpod when your AI workloads already live there: you keep automation and inference on one platform, one bill, and optionally one private network. The economics start with storage. Network Volumes decouple data from compute, so you pay per second only while the pod runs, and the volume costs a few cents per GB per month whether the pod is on or off. That lets you stop the pod overnight to drop compute spend to zero and pick up exactly where you left off in the morning.

The typical argument for a separate VPS is stability: n8n stays up even if your GPU pods get terminated. The argument against it is unnecessary fragmentation. You end up managing a second infrastructure layer, paying for an always-on server, and jumping between platforms every time you want to wire up a webhook to an inference call.

The integration story is where co-location pays off. If you are triggering a Stable Diffusion endpoint, calling a fine-tuned model on a separate Runpod pod, or processing webhook payloads through an image generation pipeline, those calls can stay on Runpod’s private network through Global Networking, avoiding cross-cloud egress and public internet round trips. Global Networking has one catch that shapes this guide: it runs only on On-Demand NVIDIA GPU Pods in Secure Cloud, so the private path means hosting n8n on a small GPU pod that never touches the GPU for compute. The prerequisites turn that into a single pick.

Runpod is the right home for n8n in one specific case: your AI workloads already run there, and you want automation on the same network. If you are not calling GPU endpoints, a generic VPS or a PaaS is simpler and probably cheaper, and this guide will not pretend otherwise. The payoff here is adjacency to inference. The steps that follow cover the Runpod-specific configuration end to end.

Architecture and Prerequisites

The target setup is one Runpod Pod running a custom Docker image that starts PostgreSQL and then launches n8n. Runpod Pods run arbitrary containers, so co-locating both services in one image is straightforward. A single Network Volume mounted at /workspace holds both n8n’s data directory (/workspace/.n8n) and PostgreSQL’s data directory (/workspace/pgdata), independent of the container’s own filesystem. The diagram below shows how these pieces fit together.

n8n and PostgreSQL co-located on a single Runpod Pod, backed by a Network Volume, exposed through the Runpod HTTP proxy, and connected to GPU pods over Global Networking

The co-located approach requires less operational overhead and works well for typical n8n deployments. It carries one tradeoff: n8n and PostgreSQL share a single container, so they start, stop, and fail together. Teams that need to scale or recover them independently can split PostgreSQL into a separate pod, covered as Option B in Section 3.

Prerequisites before you start:

  • Runpod account with credits loaded
  • Database decision made: PostgreSQL for production (n8n v2.0 dropped MySQL and MariaDB support, per the n8n v2.0 breaking changes), SQLite only for single-user testing
  • Pod type, your one real decision: the private-network path runs n8n on the smallest On-Demand NVIDIA GPU pod in a Secure Cloud datacenter that supports Global Networking (create your Network Volume in that same datacenter), while the CPU path runs a cheaper CPU pod that reaches inference over public proxy URLs. From here on, the guide flags only the steps where the two differ.
  • Docker Hub account or any container registry, required only if you build the custom PostgreSQL image in Section 3 (Option A); skip it if you use SQLite or a separate PostgreSQL pod

1. Create a Network Volume for Persistent Storage

Create the Network Volume before the pod. The pod’s datacenter must match the volume’s datacenter, and Runpod’s UI enforces this automatically by filtering available hardware to the volume’s location when you attach one during deployment.

Steps:

  1. Go to the Storage page in the Runpod console
  2. Click New Network Volume
  3. Enter a name (for example, n8n-prod-vol)
  4. Select your preferred datacenter, ideally the same one where you run other Runpod workloads, since the pod must match the volume’s datacenter
  5. Set size to 20 GB as a starting point, enough for typical n8n workflow data plus PostgreSQL write-ahead log files; scale up if you plan to store large execution logs or binary attachments
  6. Choose the Standard storage tier
  7. Click Create Network Volume

Runpod Network Volumes are network-attached storage: NVMe SSDs on servers co-located with the GPU hosts and reached over a high-speed network, so throughput is network-bound rather than local-disk speed. Runpod documents 200 to 400 MB/s in typical use, up to 10 GB/s at peak, and notes it varies with datacenter and network conditions. For n8n’s workload, mainly small reads and writes of workflow execution data, that is well above what it needs, so PostgreSQL I/O is unlikely to be the bottleneck.

Once created, the volume appears on the Storage page with its size and datacenter listed. No compute is attached yet. When you deploy a pod and attach this volume, Runpod mounts it at /workspace inside the container. Note that a Network Volume is attached at pod creation and cannot be detached from a running pod later, so decide on the volume before you deploy.

Both application directories live there:

  • /workspace/.n8n is n8n’s data directory. With PostgreSQL, it holds the encryption key and configuration; with SQLite, it also holds the database file. Workflows and credentials are stored in PostgreSQL when you use that backend.
  • /workspace/pgdata is PostgreSQL’s data directory, configured for this deployment to persist under the Network Volume mount point.

If you are migrating from an existing n8n instance, you can pre-seed the volume before first launch. See the migration question in the FAQ for the restore procedure, and refer to Runpod’s storage documentation for upload options.

2. Deploy the n8n Pod and Configure the HTTP Proxy

Deploy the pod against the volume from Section 1, expose port 5678, and Runpod’s proxy handles HTTPS with no certificate work.

Steps:

  1. Go to the Pods page and click Deploy
  2. In the deployment flow, select Network Volume and choose the volume you created in Section 1
  3. Select your pod type in the same datacenter as the volume: a GPU pod in Secure Cloud with Global Networking enabled for the private-network path, or a CPU pod for the CPU path
  4. Click Deploy On-Demand

In the Container Image field, enter your image. For initial testing:

n8nio/n8n:latest

For production, pin to a specific stable release tag before going live:

n8nio/n8n:1.x.y

Check Docker Hub for the current published n8n stable release. Using :latest in production means a routine pod restart can pull a breaking change. Pinning the tag gives you explicit control over when you upgrade.

Expose port 5678:

  1. Click Edit Template
  2. Find the Expose HTTP Ports field
  3. Enter 5678
  4. Save

Once the pod starts, n8n’s UI and webhook receiver are available at:

https://[POD_ID]-5678.proxy.runpod.net

n8n binds to 0.0.0.0:5678 by default, which satisfies the proxy’s requirement that your service listen on all interfaces rather than only localhost. Nothing extra needs to be configured on the application side.

Environment variables (non-database):

In Edit Template then Environment Variables, add the values below. You will add the database variables in Section 3, after PostgreSQL is set up, so set these first.

Variable Value
N8N_ENCRYPTION_KEY 32-character random string (generate with openssl rand -hex 16)
N8N_USER_FOLDER /workspace/.n8n
N8N_PROTOCOL https (the Runpod proxy serves HTTPS)
WEBHOOK_URL https://[POD_ID]-5678.proxy.runpod.net (add after the Pod ID is known)
N8N_HOST [POD_ID]-5678.proxy.runpod.net (add after the Pod ID is known)

You will not know the full proxy hostname until the pod launches and you can see the Pod ID, so set the other variables first, start the pod, then add WEBHOOK_URL and N8N_HOST through Edit Pod. To do that, open the pod’s detail page (click the pod name or its three-dot menu on the Pods page), click Edit Pod, add the two variables in the Environment Variables section, and click Save. The pod restarts to apply them. Setting N8N_PROTOCOL=https together with N8N_HOST makes n8n generate correct HTTPS links for the editor and for OAuth redirect URLs; without them, n8n falls back to a localhost address and those links break behind the proxy.

N8N_ENCRYPTION_KEY protects stored credentials with AES-256 encryption. If this value is lost or changed after credentials have been saved, those credentials cannot be decrypted, and n8n will fail to authenticate any stored integration (API keys, OAuth tokens, database passwords). They cannot be recovered; you have to delete and re-enter them. Store this key somewhere durable, such as a password manager or a secret manager (Doppler, 1Password, or AWS Secrets Manager), before you save a single credential.

3. Set Up PostgreSQL Co-located in the Same Pod

Production n8n needs PostgreSQL, and the stock n8nio/n8n image does not bundle it, so you ship a custom image that runs both services in one container. You have two main database options, plus a SQLite fallback for testing.

Versions drift. A few specifics below depend on what you actually run, so confirm them once against your environment: the apk PostgreSQL package versions (apk policy postgresql17), n8n’s health-check path (recent versions use /healthz), and runpodctl's command names, flags, and JSON output fields (--help and --output json). The guide states sensible defaults and does not repeat this reminder.

Option A: Custom image with a startup script (recommended)

Build a custom Docker image that extends n8nio/n8n, installs PostgreSQL, and runs a startup script that initializes the database on first launch and starts both services:

#!/bin/sh

# Fail fast: exit on any error (-e) and on use of an unset variable (-u)

set -eu

  

# Fail-fast config validation: required secrets must exist before anything runs

: "${DB_POSTGRESDB_PASSWORD:?DB_POSTGRESDB_PASSWORD must be set}"

: "${N8N_ENCRYPTION_KEY:?N8N_ENCRYPTION_KEY must be set}"

  

PGDATA=/workspace/pgdata

INIT_MARKER="$PGDATA/.n8n-init-complete"

  

# PostgreSQL's socket directory lives on tmpfs and is recreated on every start

mkdir -p /run/postgresql

chown postgres:postgres /run/postgresql

  

# Initialize only if a previous run COMPLETED init (marker), not just created the dir

if [ ! -f "$INIT_MARKER" ]; then

echo "Initializing PostgreSQL data directory..."

mkdir -p "$PGDATA"

chown postgres:postgres "$PGDATA"

su-exec postgres initdb -D "$PGDATA" --auth-host=scram-sha-256

su-exec postgres pg_ctl start -D "$PGDATA" -l "$PGDATA/postgresql.log" -w -t 30

  

# Bounded readiness wait instead of a blind sleep

i=0

until su-exec postgres pg_isready -h localhost -q; do

i=$((i + 1))

if [ "$i" -ge 30 ]; then

echo "PostgreSQL did not become ready within 30s" >&2

exit 1

fi

sleep 1

done

  

# Create the n8n role first, then a database it owns. PostgreSQL 15 and later

# no longer let an ordinary user create tables in the public schema by default,

# so the n8n role must own the database for n8n's migrations to succeed. The

# password is passed on stdin so it never appears in the container's process list.

su-exec postgres psql -h localhost -v ON_ERROR_STOP=1 <<SQL

CREATE USER n8n WITH PASSWORD '${DB_POSTGRESDB_PASSWORD}';

SQL

su-exec postgres createdb -h localhost -O n8n n8n

  

# Mark init complete ONLY after every step above succeeded

touch "$INIT_MARKER"

else

echo "Starting existing PostgreSQL instance..."

su-exec postgres pg_ctl start -D "$PGDATA" -l "$PGDATA/postgresql.log" -w -t 30

fi

  

# Ensure the n8n data directory is writable by the node user

mkdir -p /workspace/.n8n

chown node:node /workspace/.n8n

  

# Supervise n8n in the background so PostgreSQL can shut down cleanly on pod stop.

# tini (PID 1) forwards signals; the trap stops PostgreSQL to avoid WAL recovery.

su-exec node n8n start &

N8N_PID=$!

trap 'su-exec postgres pg_ctl stop -D "$PGDATA" -m fast -w || true' TERM INT

wait "$N8N_PID"

A minimal Dockerfile to accompany it:

FROM n8nio/n8n:1.x.y

  

USER root

  

# Pin OS package versions for reproducible builds, the same discipline as the image tag

RUN apk add --no-cache \

postgresql17 \

postgresql17-client \

su-exec

  

COPY start.sh /start.sh

RUN chmod +x /start.sh

  

# Surface a wedged container to Runpod and any orchestration

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \

CMD wget -q -O /dev/null http://localhost:5678/healthz || exit 1

  

# The base image's entrypoint launches n8n directly, so override it to run the

# startup script instead. tini stays as PID 1 for signal handling and zombie reaping.

ENTRYPOINT ["tini", "--", "/start.sh"]

Two facts about the base image explain the shape of all this. It is Alpine, so the script is #!/bin/sh (no bash) and the Dockerfile installs PostgreSQL with apk; and it already defines its own entrypoint and a non-root node user, so the Dockerfile overrides ENTRYPOINT and the script uses su-exec to run database commands as postgres (PostgreSQL refuses to run as root) and n8n as node. Everything else is written for an ephemeral pod: the script validates secrets before acting, waits for PostgreSQL on a bounded loop, gates initdb on a completion marker so a restart never reinitializes over your data, and traps shutdown so PostgreSQL closes cleanly. The inline comments carry the line-by-line reasoning.

Once the database is configured, add the PostgreSQL environment variables (in Edit Template then Environment Variables, or through Edit Pod on a running pod):

Variable Value
DB_TYPE postgresdb
DB_POSTGRESDB_DATABASE n8n
DB_POSTGRESDB_HOST localhost (for the co-located setup)
DB_POSTGRESDB_PORT 5432
DB_POSTGRESDB_USER n8n
DB_POSTGRESDB_PASSWORD your chosen password

Option B: Separate PostgreSQL pod via Global Networking

If you want strict service isolation, run PostgreSQL in its own pod with its own Network Volume, connected to n8n over Global Networking. This is the private-network path’s GPU and Secure Cloud requirement again, with one addition: enable Global Networking on both pods, not just n8n (the toggle appears on the Pods page). Then set:


DB_POSTGRESDB_HOST=<postgres-pod-id>.runpod.internal

Global Networking handles cross-pod DNS resolution within your Runpod account, so no public IP is required and there are no firewall rules to write. Harden the standalone database before you rely on it: set PostgreSQL’s listen_addresses to accept connections beyond localhost, and scope pg_hba.conf to the n8n pod over the private network with scram-sha-256 authentication, so the database is reachable on runpod.internal but not trivially open. This pattern adds operational overhead, two pods to manage and two volumes to monitor, and the latency difference versus localhost is likely small for most n8n query patterns, though no benchmark data is available for this specific workload. The upside is independent lifecycles: you can restart, resize, or recover either service without touching the other. On the CPU path this option is not available; co-locate PostgreSQL with Option A instead.

SQLite fallback:

For local testing or single-developer use, skip PostgreSQL entirely. Set DB_TYPE=sqlite and n8n writes its database to the configured user folder, in this deployment /workspace/.n8n/database.sqlite (because N8N_USER_FOLDER=/workspace/.n8n is set). No startup script is required. Do not use SQLite for multi-user instances or any workflow with concurrent executions; it serializes writes and becomes a bottleneck fast.

Scripted deployment with runpodctl (optional):

If you are scripting pod creation for repeatable deployments, runpodctl covers the same configuration once your custom image is published:


# List available GPU and CPU types in your datacenter to find a valid ID:

runpodctl get gpu

  

runpodctl pod create \

--name "n8n-prod" \

--image "<your-registry>/n8n-postgres:1.x.y" \

--gpu-type-id "<ID from runpodctl get gpu>" \

--volume-id "<your-volume-id>" \

--volume-mount-path "/workspace" \

--ports "5678/http" \

--env "DB_TYPE=postgresdb" \

--env "DB_POSTGRESDB_HOST=localhost" \

--env "DB_POSTGRESDB_DATABASE=n8n" \

--env "DB_POSTGRESDB_USER=n8n" \

--env "DB_POSTGRESDB_PASSWORD=your_password" \

--env "N8N_ENCRYPTION_KEY=your_32_char_key" \

--env "N8N_USER_FOLDER=/workspace/.n8n"

On the private-network path, also enable Global Networking on the pod; on the CPU path, swap --gpu-type-id for the CPU flags. Add --env "WEBHOOK_URL=..." once you have the Pod ID, or update the pod’s environment through the console. Avoid single quotes in the database password, which would break the CREATE USER statement’s SQL string, and prefer setting secrets through the console or an environment file rather than the runpodctl command line, where they can be captured in shell history.

4. Configure Webhooks and Handle the Proxy Timeout

Webhooks work the moment WEBHOOK_URL points at your proxy URL; the one Runpod-specific catch is the 100-second proxy timeout on long AI calls. WEBHOOK_URL tells n8n which address to register when it configures webhooks on external services like Stripe or GitHub. You set it to the pod’s proxy URL in Section 2, and every webhook node then hands external services the public address they can reach. The catch is what happens when those calls run long.

The proxy timeout problem with long-running AI workflows

Runpod’s HTTP proxy runs behind Cloudflare, which enforces a 100-second limit on inactive connections: if your service does not respond within 100 seconds, the connection closes with a 524 error. If a webhook triggers an inference job that takes two or three minutes to complete, the proxy closes the connection long before the response arrives. The external service then marks the webhook call as failed, even if n8n eventually finishes the work.

Two patterns handle this cleanly.

Pattern 1: Respond immediately, execute asynchronously. Your webhook node returns a 200 OK instantly, after validating the incoming payload (the endpoint is public, so verify the sender’s signature and the fields you expect before acting, as the FAQ covers). The workflow then runs the AI job through the HTTP Request node and polls a status endpoint until the job finishes. Bound that poll with a maximum attempt count and a backoff delay so a stuck job fails the workflow cleanly instead of looping forever, then push results to a separate channel: another webhook, a database write, or a Slack message.

Pattern 2: Use polling-based triggers. Replace the webhook trigger with an n8n Schedule node that polls a status endpoint at a regular interval, for example every 30 seconds. This is less real-time, but it sidesteps the proxy timeout entirely.

For workflows that return well within that 100-second window, such as a short summarization or a simple classification call, the timeout is not an issue. It only becomes a problem when the AI workload itself runs long.

If you also need to expose n8n’s REST API to internal Runpod services without going through the HTTP proxy, add TCP port exposure. Unlike the HTTP port, which routes through Runpod’s HTTPS proxy, TCP exposure gives you a raw public IP and assigned port number, useful for programmatic API access that does not need HTTPS termination and is not subject to the 100-second proxy timeout. Enter 5678 in the pod’s TCP ports field; the pod’s Connect menu then shows the assigned public IP and external port number.

5. Harden for Production and Plan Next Steps

Verify persistence first. Terminate the pod, then restart it with the same Network Volume attached. If your workflows and credentials are all there, persistence is working. The cycle takes a few minutes and is worth doing before you wire up any real workflows.

Back up the database. Persistence protects you from pod termination, not from accidental deletion or corruption. Schedule a periodic pg_dump to the Network Volume (or off-platform through the S3-compatible API) so you can restore a known-good state:

#!/bin/sh

set -e # abort on a failed dump so you never keep an empty backup file

su-exec postgres pg_dump -h localhost n8n > "/workspace/n8n-backup-$(date +%F).sql"

# Prune dumps older than 14 days so the volume does not fill up

find /workspace -maxdepth 1 -name 'n8n-backup-*.sql' -mtime +14 -delete

Run it from a cron job inside the pod, or from a separate maintenance task.

Manage Pod ID stability carefully. The Pod ID stays consistent across stop and start cycles, so your webhook URL stays stable through normal operations. It changes if you delete the pod and create a new one, and a Runpod-initiated migration (which can follow the host maintenance or eviction events noted below) also assigns a new Pod ID and IP address. Treat the proxy URL as stable for routine stop and start, but not guaranteed across a pod’s full lifecycle. After any event that issues a new Pod ID, update WEBHOOK_URL and N8N_HOST in the pod’s environment variables through Edit Pod once the new pod starts. External services (Stripe, GitHub, and the like) will also need their webhook endpoints updated to the new URL.

For teams managing multiple environments, that update burden is reason enough to route webhook traffic through a stable custom domain. A practical approach is a Cloudflare Worker or a lightweight proxy (Nginx or Caddy) on an external host that forwards requests to the current pod’s proxy URL; update that one forwarding rule after each redeploy instead of updating every upstream webhook. Note that pointing a CNAME directly at [POD_ID]-5678.proxy.runpod.net is not a supported pattern, because the proxy routes by Pod ID, so the target changes when the Pod ID changes.

On cost: Start on-demand for per-second billing, then assess reserved capacity after a week of usage data. The private-network path’s GPU pod bills at GPU on-demand rates for a workload that never uses the GPU, so reserved or savings pricing pays off sooner there; a CPU-path pod bills lower and rarely justifies reserved capacity on its own. Either way, the Network Volume data carries over, so switching pod types needs no re-architecture, only re-attaching the volume to the new pod.

On availability: Pods can terminate due to host maintenance or eviction events. A lightweight runpodctl cron job that checks pod status and restarts on termination keeps your automation layer running without manual intervention. A minimal example:


# Add to crontab (crontab -e): every 5 minutes, start the pod if it is not running.

*/5 * * * * [ "$(runpodctl get pod <POD_ID> --output json | jq -r '.[0].desiredStatus // empty')" = "RUNNING" ] || runpodctl start pod <POD_ID>

Parsing JSON with jq is sturdier than grepping console text, and // empty keeps the check from breaking if the field is absent.

Start simple, then harden. The fastest way to a working instance is to deploy n8nio/n8n:latest against a fresh Network Volume and confirm the login screen loads over the proxy. Once that path works, swap in the custom PostgreSQL image from Section 3 through Edit Pod and restart. Validating networking and persistence before you add the database layer keeps each failure easy to isolate.

Deploy your first n8n instance on Runpod

Frequently Asked Questions

Do webhooks from external services like Stripe and GitHub work, and how do I secure them?

Yes. Because the pod’s proxy URL is public, external services reach your webhook nodes with no firewall or DNS setup. That same public exposure is the reason to add authentication. Verify inbound webhook signatures inside the workflow (Stripe and GitHub both sign their payloads, and the HTTP Request and Code nodes can check the signature header before the workflow acts on it), and protect the n8n editor itself with n8n’s built-in user management and two-factor authentication. Runpod’s proxy documentation makes the same point: the Pod ID adds obscurity, not security, so implement proper authentication in your application.

How do I upgrade n8n without losing data or credentials?

Change the pinned image tag in Edit Pod (for example, from your current n8nio/n8n:1.x.y to a newer pinned release) and restart the pod. Your PostgreSQL database and .n8n directory stay on the Network Volume, so workflows and execution history carry across the upgrade untouched. Credentials keep working as long as N8N_ENCRYPTION_KEY is unchanged, which is exactly why that key belongs in a secret manager rather than only in the pod’s environment. Always upgrade from one pinned tag to another, never through :latest, so you can roll back by pointing the image at the previous version if a release misbehaves.

How do I migrate an existing n8n instance to Runpod?

Use Runpod’s S3-compatible API to upload your existing SQLite database file or a PostgreSQL dump to the Network Volume before first launch. For a SQLite migration, place the database.sqlite file at the configured user folder path, in this deployment /workspace/.n8n/database.sqlite (because N8N_USER_FOLDER=/workspace/.n8n is set), and n8n will read it on startup. For a PostgreSQL migration, generate the dump with pg_dump -h <old-host> -U n8n n8n > n8n_backup.sql, upload it to the Network Volume (for example, at /workspace/n8n_backup.sql), and add a restore step to your startup script that runs before n8n starts:

# Inside the first-run block, after the database is created:

if [ -f "/workspace/n8n_backup.sql" ]; then

su-exec postgres psql -h localhost -U n8n -d n8n -f /workspace/n8n_backup.sql

rm /workspace/n8n_backup.sql # Remove after a successful restore

fi

Related articles

View All
No items found.

Build what’s next.

Build, train, and scale AI workloads on Runpod with cloud GPUs, Serverless, and Clusters.