n8n runs on any server you control, and in 2026 the sensible way to do that is a Docker Compose stack on an Ubuntu VPS with PostgreSQL behind it and a reverse proxy in front. This guide is the map for the whole thing: why people move off n8n Cloud to a self-hosted instance, what n8n 2.0 changed, how big the VPS has to be, the compose file I would start from today, the environment variables that must be right before the first launch and then the operational side.
Each topic gets the short version here and a link to the tutorial where the details live. I run n8n for LumaDock's own billing automations, so a few of the numbers below come from that instance and not from a spec sheet.
Why self-host n8n
Two reasons come up more than the rest: the execution meter and where the data sits. n8n Cloud counts one run of a workflow as one execution and every plan carries a monthly allowance, so a workflow that fires on every incoming order gets expensive in a way that has nothing to do with how much work it does. The self-hosted Community edition has no meter; a sync that runs 20,000 times a month costs the same as one that runs twice. I wrote the longer version of this argument as a blog post on what changed for self-hosted n8n with Docker in 2026, so here I'll keep to the facts. The second reason is that the instance lives in the region you picked and its executions sit in a database you administer, which removes n8n GmbH from your processor list; the article on self-hosted n8n and GDPR compliance covers the records-of-processing side of that. Two arguments, and with a legal team in the room the second usually settles it.
The license is the third thing to know before you commit. n8n ships under the Sustainable Use License, which n8n calls fair-code and which is not an OSI-approved open source license, and the n8n community license page lists both sides in plain words. Internal business use, consulting and building integrations are all allowed. Hosting n8n and charging people to access it is not, and neither is white-labeling it inside a product you sell.
And the honest caveat: Community edition leaves out SSO, environments, Git source control, external secrets, S3 binary storage, log streaming, multi-main and shared projects. Registering with an email address unlocks folders and debug-in-editor for free; the rest needs a Business or Enterprise key, which also works on a self-hosted instance.
What changed in n8n 2.x
Stable as I write this is 2.38.5 (September 9, 2026); the 2.0 release itself dates from December 2025. Most 2025 tutorials still describe 1.x. The differences that touch a VPS deployment:
- Task runners are on by default and
N8N_RUNNERS_ENABLEDis deprecated, so leave the variable out. - Docker tags were renamed:
latestbecamestableandnextbecamebeta. The old names still resolve; pin a version number anyway. - MySQL and MariaDB are gone as n8n's own database. SQLite (pooled driver, WAL mode) and PostgreSQL remain.
N8N_BLOCK_ENV_ACCESS_IN_NODEdefaults to true. Reading$envfrom a Code node fails until you set it to false.NODES_EXCLUDEships with Execute Command and Local File Trigger already in it, and Read/Write Files only sees~/.n8n-files.- Workflows are published and unpublished now. The CLI got
publish:workflowandunpublish:workflowto match, withupdate:workflowon its way out. WEBHOOK_URLwas renamedN8N_WEBHOOK_URLin 2.35; the old spelling still resolves but n8n warns about it on startup.- Python in the Code node needs external task runners (the
n8nio/runnerssidecar). The Pyodide implementation is gone. - In-memory binary data mode was removed. Regular mode defaults to
filesystem, queue mode todatabase.
Settings has a Migration Report for global admins that lists what will break before you jump from 1.x.
n8n VPS requirements and sizing
n8n publishes no formal CPU and RAM table. The closest official numbers are in the cloud provider guides, where a 2 vCPU / 2 GB plan is called enough for most usage and 4 GB with 2 vCPU becomes the floor once you add the n8n Assistant sandbox stack. The docs' memory page says consumption depends on JSON size, binary size, node count, Code nodes, manual executions and concurrency, which is true and not very useful for picking a plan. So here is what I use.
What consumes memory on an n8n VPS
The main n8n process is a Node.js application. On my instance it sits around 400 to 500 MB of resident memory after a day of light traffic, and the internal task runner it spawns for Code nodes adds a second Node process on top. Postgres 17 idles under 100 MB with default settings and Caddy is a rounding error. Every queue mode worker is another full n8n process with its own runner, so a worker costs roughly what the main costs before it executes anything. The spikes come from executions: an HTTP Request node that pulls a 40 MB JSON response holds it in memory, and a Loop Over Items with a large batch multiplies that.
Sizing table
| Use | vCPU | RAM | NVMe disk | Why |
|---|---|---|---|---|
| Testing, a personal instance, a handful of scheduled workflows | 1 | 2 GB | 20 GB | n8n plus Postgres fit in about 1 GB with room for a docker compose pull. Below 2 GB the OOM killer visits during updates. |
| Production, single instance, regular mode | 2 | 4 GB | 40 to 60 GB | Headroom for concurrent executions, Code node payloads and Postgres shared buffers. Disk covers 14 days of execution data plus binary files. |
| Queue mode on one VPS: main, two workers, Redis, Postgres | 4 | 8 GB | 80 GB | Three n8n processes at concurrency 10 each. Redis holds pending jobs and needs AOF persistence on disk. |
| ETL over large tables, binary-heavy flows, AI agents with local models | 8+ | 16 GB+ | 160 GB+ | RAM is the ceiling for batch size. Local models want a GPU VPS or a second machine. |
If you already have an instance and want it to use less of what it has, the n8n performance tuning guide starts with the execution data settings and the runner limits, which is where most of the savings are. The disk column assumes NVMe, which every LumaDock VPS plan has, because SQLite and Postgres both suffer on spinning disks the moment the executions table grows.
Location
Put the VPS near the APIs it calls most and near the people who open the editor. For EU customers that means an EU zone. LumaDock has zones in Frankfurt, Paris, Amsterdam, Helsinki, Warsaw, Madrid, Bucharest, London and New York, and the data center list notes which of those are inside the EU. Pick by data residency first and by editor round-trip second.
The standard stack: Ubuntu 24.04, Docker Compose, PostgreSQL 17 and Caddy
Ubuntu 24.04 LTS, Docker Engine with the Compose plugin, a pinned n8n image, Postgres 17 and Caddy for TLS. The official cloud provider guides use the same shape with Caddy or Traefik in the proxy seat. I prefer Caddy because a working reverse proxy with automatic Let's Encrypt certificates is four lines and the certificate renewal is one less cron job I have to remember exists.
Install Docker
The steps on the Docker Engine install page for Ubuntu use Docker's own apt repository, not the docker.io package from Ubuntu, and they end with:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
Log out and back in, then docker compose version should print a v2 version.
The compose file
Create a directory such as /opt/n8n with a compose.yaml in it and keep the secrets in a .env file next to it. n8n has no ports: block at all: Caddy talks to it over the compose network by service name, so port 5678 never touches a public interface.
services:
n8n:
image: n8nio/n8n:2.38.5
restart: unless-stopped
env_file: .env
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}
volumes:
- n8n_data:/home/node/.n8n
- ./local-files:/files
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:17
restart: unless-stopped
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 5
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- n8n
volumes:
n8n_data:
pg_data:
caddy_data:
caddy_config:
There is no version: key because Compose v2 ignores it and warns. Picking the n8n VPS template during ordering skips this file entirely, since it deploys with Docker and Caddy pre-configured on Ubuntu, and you still get root to change anything afterwards. The image is pinned to 2.38.5 because n8n cuts a minor release most weeks and you want to choose when you take one.
Still on SQLite from an older install? It keeps working in 2.x and for a personal instance it is fine. I moved my own instance to Postgres after the executions table crossed a few hundred megabytes, and the PostgreSQL vs SQLite comparison for n8n has the migration steps and the numbers behind that. Queue mode requires Postgres regardless.
Caddyfile and DNS
The A record has to exist first: Let's Encrypt validates over port 80 the moment Caddy comes up. The Caddyfile is the one from n8n's own Hetzner guide:
n8n.example.com {
reverse_proxy n8n:5678 {
flush_interval -1
}
}
The flush_interval -1 line turns buffering off so the editor's push channel stays open. The full step-by-step, UFW rules included, is in the Ubuntu 24.04 install guide for n8n with Docker and Caddy; this page stays at the overview level. If you'd rather run nginx on the host with certbot, the guide to running n8n behind an nginx reverse proxy has the server block with the WebSocket upgrade headers nginx does not add on its own, which is the usual cause of "Connection lost" in the editor.
First launch
Run docker compose up -d, wait 20 seconds, open the hostname. The first thing you see is a setup page, and the account you create on it is the instance owner. Basic auth has not existed since 1.0, so a tutorial that sets N8N_BASIC_AUTH_ACTIVE is describing 2023 software. Turn on 2FA for the owner under Settings > Personal right away; its free in every edition.
Environment variables to set on day one
These go in the .env file the compose above reads. Some of them cannot be changed later without consequences.
N8N_ENCRYPTION_KEY=change-me-to-a-long-random-string
POSTGRES_PASSWORD=change-me-too
N8N_HOST=n8n.example.com
N8N_PROTOCOL=https
N8N_PORT=5678
N8N_EDITOR_BASE_URL=https://n8n.example.com/
N8N_WEBHOOK_URL=https://n8n.example.com/
N8N_PROXY_HOPS=1
GENERIC_TIMEZONE=Europe/Amsterdam
TZ=Europe/Amsterdam
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_SAVE_ON_PROGRESS=false
EXECUTIONS_DATA_MAX_AGE=168
EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000
N8N_DIAGNOSTICS_ENABLED=false
N8N_VERSION_NOTIFICATIONS_ENABLED=false
The encryption key comes first. n8n generates a random one on first launch and stores it in the config file inside the n8n_data volume, and every credential in the database is encrypted with it. Set it yourself before the first start so it also lives in your .env and your backups; if the env var and the file ever disagree you get a "Mismatching encryption keys" error, and the tutorial on how to rotate the n8n encryption key explains that one along with the rotation switch N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION. I lost a test instance's credentials in 2024 to exactly this: a volume recreated on a rebuild, key still only in the old volume, workflows intact and every API connection re-entered by hand.
N8N_WEBHOOK_URL is how n8n knows what to print in the Webhook node and what to register with OAuth providers; it does not read the Host header from the request. Without it, n8n builds the URL from N8N_PROTOCOL, N8N_HOST and N8N_PORT, which works if all three are set and produces http://localhost:5678/webhook/... if they aren't. The symptom table for when a webhook still shows the wrong host, including the ALB and Cloudflare cases, is in fixing webhook URL issues behind a reverse proxy. N8N_PROXY_HOPS=1 tells n8n to trust one layer of X-Forwarded-* headers for client IPs, and Caddy sends those by default.
GENERIC_TIMEZONE defaults to America/New_York and drives the Schedule Trigger, so set it before you build a single cron-style workflow. TZ is the OS-level variable for the container and I set both to the same value.
The execution data block is the docs' own recommended example: keep failed executions, drop the payloads of successful ones, prune after seven days or 50,000 rows.
Security baseline for a self-hosted n8n instance
Most of the baseline is already in place if you followed the compose above: 5678 is not published, TLS terminates at Caddy, the owner has 2FA and the 2.x defaults keep $env out of Code nodes and Execute Command switched off. What remains is the host and the edges, and the n8n security best practices guide orders them by impact with the reasoning. The short form: UFW with 22, 80 and 443 open. SSH on keys only. N8N_PUBLIC_API_DISABLED=true unless a pipeline uses the REST API. N8N_SSRF_PROTECTION_ENABLED=true (2.12 and later) so an HTTP Request node cannot be pointed at the Postgres container. Task runners in external mode with the distroless n8nio/runners image if people you don't fully trust write Code nodes.
Public webhooks are their own topic. A Stripe or GitHub endpoint is reachable by anyone who guesses the path, so either use the provider's trigger node, which verifies signatures for you, or enable Raw Body on the Webhook node and check the HMAC in a Code node. The walk-through on how to verify webhook signatures in n8n covers both routes, including the NODE_FUNCTION_ALLOW_BUILTIN=crypto the runner needs before require('crypto') works. Stripe signs a timestamp together with the body, so a check that ignores the timestamp is wrong.
Single sign-on is where the Community edition stops. SAML, OIDC and LDAP login are Business and Enterprise features on self-hosted, configured under Settings or through the N8N_SSO_* variables, and the n8n SSO options article covers what each tier gets. A Community instance has the owner plus invited users, 2FA and API keys. The common workaround puts Authelia or Cloudflare Access on the editor path while leaving /webhook/ open, and that article has the nginx location split for it.
Backups: What a restore needs
A restore needs four things: the database (a pg_dump or the SQLite file with the container stopped), the encryption key, the binary data folder if you run filesystem mode and the compose file with its .env. The guide on how to back up and restore n8n has the cron script, the off-server copy and the restore drill, and it says how often I test mine, which is less often than it should be. Workflow and credential exports from the CLI are the second layer, useful for moving single workflows between instances and as the only version history Community edition keeps beyond 24 hours.
Execution data and pruning
Pruning is on out of the box with a two-week window and a row cap, executions go through a soft delete before the hard one, and running, waiting or annotated executions are never touched. On SQLite the freed space is reused, not returned to the filesystem, which is what DB_SQLITE_VACUUM_ON_STARTUP is for.
Don't hand-write DELETE statements against execution_entity when the disk fills overnight. The reference on how to prune n8n executions with EXECUTIONS_DATA_MAX_AGE has every variable, the per-workflow overrides in the UI and the Postgres table sizes to watch, and it's the page I open when a customer's instance has a 20 GB database and no idea why. In 2.x the payload sits in execution_data with foreign keys and there is a soft-delete column, so a manual delete leaves orphans.
Scaling path from one VPS to a dedicated server
In the order I'd do it.
Step 1: Tune before you scale
Execution data settings, Postgres instead of SQLite, N8N_CONCURRENCY_PRODUCTION_LIMIT to cap parallel runs on main and NODE_OPTIONS=--max-old-space-size if you see JavaScript heap errors. The docs' own example is to process 200 rows per execution instead of 10,000. Most single-instance problems end here.
Step 2: Queue mode with workers
EXECUTIONS_MODE=queue on every n8n service, Redis in the compose file and one or two worker containers running n8n worker --concurrency=10 with the same N8N_ENCRYPTION_KEY and the same database as main. The single-VPS compose for this, health checks and Redis persistence included, is in the n8n queue mode guide with Redis and workers. Main hands production executions to Redis and the editor stays responsive during a burst. Binary data goes to database mode (the queue mode default), since filesystem mode is not supported there and S3 needs a Business license.
Step 3: More workers and webhook processors
Worker count depends on vCPU, RAM and how much of each execution is spent waiting on remote APIs, and the article on scaling n8n workers and webhook processors does the maths per box along with the lock and stall timers, N8N_GRACEFUL_SHUTDOWN_TIMEOUT and the durable scheduler from 2.36. Dedicated webhook processors (n8n webhook) take /webhook/* and /webhook-waiting/* traffic off main behind a load balancer and scale horizontally on Community edition.
Step 4: A second server over private networking
When Postgres and Redis compete with workers for RAM, move them to a second VPS on a private network with nothing bound to a public interface. The two-server layout is in the guide to private networking for n8n, Postgres and Redis, which also covers WireGuard for the case where the provider has no private network. In short: Postgres on the private IP, Redis bound there with a password, UFW allowing 5432 and 6379 from the private subnet only.
Step 5: High availability and its limits
Community edition runs exactly one main process; multi-main needs an Enterprise license and a sticky-session load balancer, and the high availability for n8n article is honest about that boundary. Without it you get workers and webhook processors that survive a main restart, Postgres with a replica, Redis persistence, health checks on every container and a tested restore as the real answer for the main. The compose healthcheck fragments are in that article too.
Step 6: Dedicated hardware
An 8 vCPU VPS with 16 GB carries a lot of n8n. Past that, or when workers are bottlenecked on shared storage, a dedicated server stops the neighbour effect and gives Postgres the disk to itself. I have not run n8n on bare metal with more than 32 workers, so how the Redis connection count behaves past that point is something I cannot tell you from experience.
Monitoring and troubleshooting
/healthz returns 200 when the process is up and says nothing about the database; /healthz/readiness returns 200 only once the DB is connected and migrated, and that is the one to point an uptime check at. The queue gauges (n8n_scaling_mode_queue_jobs_waiting and friends), the cardinality cost of the N8N_METRICS_INCLUDE_* flags and the alert rules I'd start with are in the guide to monitoring n8n with Prometheus and Grafana, along with a short section on the OpenTelemetry tracing that arrived in 2.19. N8N_METRICS=true exposes /metrics for Prometheus, off by default and never to be reachable from the internet, and workers need QUEUE_HEALTH_CHECK_ACTIVE=true to expose theirs.
The literal error strings and their fixes sit one per heading in the n8n troubleshooting guide for common errors so they show up when you paste the message into a search engine: "Mismatching encryption keys", the secure cookie refusal over plain HTTP, runner timeouts and the like. When something is already broken and the message is unfamiliar, docker compose logs -f n8n with N8N_LOG_LEVEL=debug is the first move.
Updates and version pinning
Change the tag in compose.yaml, then:
docker compose pull
docker compose down
docker compose up -d
Read the 2.x release notes before you bump and take a database dump before you bump. Never run a main and workers on different versions, even for a minute, because the queue payload format between them is not guaranteed to match. Monthly is the cadence n8n itself suggests and monthly is what I do: first Monday, after coffee, with the release notes open in the other tab. Pinning the tag is what makes that a decision instead of something that happens to you at 3 am when a container restarts and pulls a fresh stable.
Two more version facts. Postgres 16, 17 and 18 are the supported majors as of mid-2026 and the window shifts every November, so postgres:17 is safe for a while. And the n8n docs site was restructured this year into Deploy, Build, Integrations, Connect and Administer, which means every docs.n8n.io/hosting/... link in a 2025 article is dead; the same content now lives under /deploy/host-n8n/.
n8n 3.0 in October 2026: Docker only
n8n 3.0 is scheduled for October 2026 and the headline change for self-hosters is that npm and npx n8n installs stop being supported. Self-hosted n8n will require a Docker-based deployment. The n8n 3.0 breaking changes page also lists the removal of the legacy Function, Function Item and Item Lists nodes, the v1 AI Agent modes, $getPairedItem and Chat Hub, plus lower Compression node limits and encryption key rotation on by default. The compose stack above is unaffected. A workflow that still uses a Function node gets flagged by the Migration Report, and the Code node with $input.all() is the replacement.
Workflows as code: Exports, Git and environments
Git source control and Environments are Business and Enterprise features. On Community edition the equivalent is n8n export:workflow --backup --output=workflows/ through docker compose exec -u node n8n, committed on a schedule, with separate instances per environment. A GitHub Actions job then pushes a workflow to production through the public REST API or imports it over SSH and calls publish:workflow --id=. The pipeline YAML is in the article on CI/CD for n8n with GitHub Actions, and the environment side (matching credential names across dev and prod, the 24-hour workflow history on Community, the visual diff from 2.13) is in n8n version control and environments. The two are split on purpose: one is the pipeline, the other is what you feed it.
Building on the instance: ETL, AI agents and custom nodes
A nightly pipeline that pages through an API with the HTTP Request node, batches with Loop Over Items, transforms in a Code node and upserts into Postgres is the most common shape, and the tutorial on how to build an ETL pipeline with n8n on a VPS walks one of those end to end with the current node names. One sizing note: the Compression node accepts 2 GiB decompressed today and drops to 256 MiB in 3.0.
The AI Agent node and the LangChain node family run wherever the instance runs, so a self-hosted agent can call an Ollama model on the same VPS or on a GPU VPS over the private network and the prompt data never leaves. The guide to n8n AI agents on a VPS with OpenAI, Anthropic and Ollama covers MCP, the instance-level MCP server from 2.33 and the n8n Agents beta from August 2026, including the host.docker.internal trick for reaching Ollama from a container on Linux. Simple Memory does not work in a queue mode production workflow; Postgres Chat Memory and Redis Chat Memory do.
When the HTTP Request node plus a credential is not enough, the walk-through on how to build custom n8n nodes with the n8n-node CLI starts from n8n-node new and ends with the verified nodes program and why only verified nodes reach n8n Cloud. A node built that way loads into your instance through N8N_CUSTOM_EXTENSIONS or, once on npm as n8n-nodes-*, through Settings > Community Nodes.
When n8n Cloud is the better choice
Self-hosting is the right call for most people who reach this page, and it is still not universal. Pick n8n Cloud if nobody on the team wants to own a Linux box: updates, backups, certificates and Postgres become somebody else's problem, and for a marketing team with a dozen workflows that is worth more than the meter costs.
It also makes sense when the execution count is small and predictable. Managed OAuth, on Cloud since 2.11, saves you registering your own OAuth application with Google and Microsoft. And if you need SSO or environments and don't want to run a licensed self-hosted instance, Cloud is the shorter path.
Stay self-hosted if any of these apply: data residency is a contract term, a single workflow fans out over thousands of items a day, you need Python imports in Code nodes (Cloud disallows them), you install unverified community nodes or you want Ollama and a local vector store on the same network as the instance.
In those cases the VPS wins on capability.... and price was never the strongest argument anyway.

