n8n.cloud caps active workflows and monthly executions, and it cannot reach anything sitting in your private network. Running n8n on a VPS removes both limits for the price of a small server. This covers the compose file, the data volume, three environment variables that quietly break webhooks when misconfigured, logging in through the built-in owner account instead of basic auth, an nginx and ufw perimeter, and what actually needs backing up so a lost server doesn't mean lost workflows.
n8n.cloud works fine right up until a plan caps the number of active workflows or monthly executions, or until a workflow needs to talk to a database that has no business being reachable from the internet. Running n8n on a VPS removes both problems for the price of a cheap server: as many workflows and executions as the box can carry, and direct access to whatever else lives on your own network. What follows is the compose deployment, the data volume, the three environment variables that quietly break webhooks the moment one of them is wrong, the built-in owner account that replaced basic auth years ago, an nginx and ufw perimeter, and what actually has to be backed up.
Short version. n8n runs from one compose file off the
n8nio/n8nimage, listens on port 5678, and keeps everything, workflows, connection credentials, execution history and the encryption key, in a SQLite file under/home/node/.n8n, which has to be mounted to the host. Resource-wise it asks for little: n8n's own documentation puts an idle instance around 100 MB, with a working range of 320 MB to 2 GB for ordinary workloads. Not remotely LLM-stack territory. Behind a reverse proxy the address n8n builds itself fromN8N_HOST/N8N_PROTOCOL/N8N_PORTdoes not match the real public URL, and that address is exactly what gets registered with external services as the webhook target. Get it wrong and integrations fail silently. The correct address goes inN8N_WEBHOOK_URLinstead. Logging in via environment variables (N8N_BASIC_AUTH_ACTIVE) does not work on current releases; access runs through the built-in owner account. Do not publish the port:127.0.0.1:5678:5678in compose, nginx with a real domain and HTTPS in front, ufw open only on 80 and 443.
n8n is a visual automation tool: you wire up nodes into a flow, a webhook fires, it calls an external API, it writes the result to a spreadsheet, without a custom backend for every scenario. n8n.cloud is the managed version of the same product, and for a one-off task it is a fair choice: nothing to run, updates handle themselves. But the managed plan carries two structural limits that eventually get in the way.
The first is the plan tier itself: active workflow counts, monthly executions, seats. The second matters more to a developer or an agency: a cloud-hosted n8n physically cannot reach a service that is not exposed to the open internet. A client's internal PostgreSQL, an API sitting on a private network, a local Redis instance, all of that either has to be opened up temporarily for the sake of one integration or routed through a tunnel. On your own VPS, n8n and the target service can share a private network with no open port at all.
There is also a licensing reason this is legitimate and free rather than a grey area. n8n ships under the Sustainable Use License: free to install, modify and run for your own internal work, restricted only from being resold as a hosted service to other people. Self-hosting n8n on a VPS for your own team or client projects is squarely inside that permission, not outside it.
What matters | n8n.cloud | n8n on your own VPS |
|---|---|---|
Active workflow and execution caps | Set by the plan | Bounded only by server resources |
Reaching internal services without an open port | No, needs a public address | Yes, if the services share a network or host |
Version upgrades and security patches | Handled by n8n | Your job: |
Data backups | The provider's responsibility | Yours, covered below |
Monthly cost | Paid seat-based tier | The VPS plan, regardless of workflow count |
n8n is not a language model and not a vector database: it does not keep gigabytes of weights in memory, it runs workflows step by step. n8n's own documentation puts an idle instance at roughly 100 MB, with a typical range of 320 MB to 2 GB for straightforward workloads, depending on how many workflows exist, how much data passes through individual nodes, and how many executions run at once. Treat that as a guideline, not a guarantee: one node that parses a large PDF or loads a heavy CSV entirely into memory can spike usage well above the average for a moment.
Setup | Whole-server RAM to plan for |
|---|---|
Personal automation, a handful of workflows, occasional triggers | 1-2 GB |
Small team, regular webhooks and API integrations | 2-4 GB |
Many parallel executions, heavy in-node data processing, external PostgreSQL | 4-8 GB and up |
Size the disk the same modest way: the image and its layers add up to under a gigabyte, and the SQLite file grows from there along with execution history. n8n keeps completed executions indefinitely by default, and on an active instance that history can grow into real disk usage over months and years. On a small disk, set up automatic cleanup of old executions in the instance settings, which is its own environment variable and outside the scope of this piece.
Install Docker from Docker's own repository rather than the distribution one. The docker.io package lags, and docker compose is packaged differently there. The sequence below is the official one and is identical on Ubuntu 22.04, 24.04 and 26.04.
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
$(. /etc/os-release && echo "$VERSION_CODENAME") fills in your release codename, so there is nothing to hand-edit per version.docker-compose-plugin is the two-word docker compose. The hyphenated docker-compose is a separate, older tool.Verify with docker compose version, which should print something like Docker Compose version v2.x.x. Getting docker: 'compose' is not a docker command instead means the plugin did not install; rerun the last line.
Now the directory for the data. n8n keeps workflows, connection credentials and execution history in a SQLite file under /home/node/.n8n, a fixed path inside the image that there is no reason to change. That directory has to be mounted to the host, or a single docker compose down takes every workflow with it.
sudo mkdir -p /opt/n8n/data
cd /opt/n8n
sudo chown -R 1000:1000 /opt/n8n/data
data is where /home/node/.n8n gets mounted; all persistence lives here.chown 1000:1000 matters because the process inside the image runs as the node user, not root, and without write access it cannot create the database file on first start.Write /opt/n8n/docker-compose.yml:
services:
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
environment:
- N8N_HOST=n8n.example.com
- N8N_PROTOCOL=https
- N8N_PORT=5678
- N8N_WEBHOOK_URL=https://n8n.example.com/
- N8N_PROXY_HOPS=1
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
- N8N_ENCRYPTION_KEY=replace-with-a-random-string-of-32-chars-or-more
- GENERIC_TIMEZONE=Europe/Amsterdam
- TZ=Europe/Amsterdam
volumes:
- ./data:/home/node/.n8n
127.0.0.1:5678:5678 publishes the port on loopback only; it does not exist from outside the server. nginx will be the only thing facing the internet, covered in the perimeter section below.N8N_HOST, N8N_PROTOCOL and N8N_PORT are what n8n uses internally to describe itself. Behind a reverse proxy that description does not match reality: the container listens on 5678 over plain HTTP, while the outside world expects a domain over HTTPS on 443.N8N_WEBHOOK_URL is the public address n8n shows in the editor UI and registers with every external integration, such as a Telegram or Stripe webhook. This is the current variable name; the older WEBHOOK_URL has been deprecated since version 2.35.0. Leave it unset, or get the domain wrong, and the webhook still gets created, but the external service knocks on the wrong address with no obvious error at creation time.N8N_PROXY_HOPS=1 tells n8n that exactly one reverse proxy sits in front of it and that the X-Forwarded-Proto header from that proxy can be trusted. Without it n8n can conclude a request arrived over plain HTTP even when everything outside was HTTPS, which trips the cookie protection covered below.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true requires the settings file holding the encryption key to be readable only by its owner. It comes from the official quickstart example, and its purpose is not leaving secrets world-readable inside the volume.N8N_ENCRYPTION_KEY: skip this and n8n generates its own key and saves it into the same volume on first start. Setting your own is worth doing for backups: if you ever want to restore only a database dump onto a new server without moving the entire volume, the encrypted connection credentials in that dump are unreadable without this exact key. Generate one with openssl rand -hex 32.GENERIC_TIMEZONE and TZ set the timezone that schedule (cron) triggers inside workflows run against. Skip it and triggers fire in UTC, which for something like "every day at 9 AM" is usually not what was intended.Bring it up:
cd /opt/n8n
sudo docker compose up -d
sudo docker compose ps
ps should show status running and a ports column reading 127.0.0.1:5678->5678/tcp. A status of restarting sends you to sudo docker compose logs -n 50 n8n, and at this stage the cause is almost always volume permissions.
If you have deployed things like WordPress or older versions of other open-source projects, the instinct is to look for variables shaped like *_BASIC_AUTH_USER and put a username and password straight into the compose file. n8n used to have exactly those (N8N_BASIC_AUTH_ACTIVE, N8N_BASIC_AUTH_USER, N8N_BASIC_AUTH_PASSWORD), but basic auth was removed from the product starting with version 1.0, and the variables no longer do anything. Leaving them in the file is harmless; it just has no effect.
Instead, the first time you open the interface, n8n itself presents a form to create an owner account: email and password. This is not an optional screen you can skip past; there is no reaching the interface without it. From that account, the user management section lets you invite additional users with narrower roles, the same idea covered in the AnythingLLM piece: a single shared entry point versus real roles for a team.
Reach the interface for that first-run setup without exposing anything, over an SSH tunnel:
ssh -L 5678:127.0.0.1:5678 root@YOUR_IP
While that session stays open, http://127.0.0.1:5678 in your browser is the port on the server. Create the owner account now, before a domain and public access exist, not after.
Like any other Dockerized service, the same perimeter rule applies here that the AnythingLLM on a VPS piece covers in detail: Docker writes its own iptables rules when publishing a port, ahead of anything ufw manages, so a rule like ufw deny 5678 has no effect while ufw status still looks clean. The 127.0.0.1:5678:5678 line in the compose file above already handles this: the port simply does not exist from outside, and every external request has to go through nginx.
The server block is an ordinary reverse proxy with the headers n8n needs for webhooks and the editor's live updates:
server {
listen 80;
server_name n8n.example.com;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
X-Forwarded-Proto $scheme is exactly the header n8n reads, thanks to N8N_PROXY_HOPS=1, to know the outside world saw HTTPS even though it answers over plain HTTP itself.Upgrade and Connection keep the connection the editor uses for live UI updates open; without them parts of the interface update in visible jumps.client_max_body_size 50M caps the request body reaching webhooks. nginx defaults to 1 MB, and any webhook carrying a file or a sizeable JSON payload hits a 413. 50 MB is a reasonable starting point; raise it for your own load.Then the usual order: sudo nginx -t first, and only on a clean result sudo systemctl reload nginx. TLS comes as a separate step once plain HTTP already answers, typically through certbot:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d n8n.example.com
Once the certificate is in place, N8N_PROTOCOL=https and the https:// URL in N8N_WEBHOOK_URL need to match what the server actually serves, or the browser starts complaining about an insecure cookie during login, because n8n expects HTTPS and gets HTTP, or the reverse. The full server block, the minimal ufw rule set, and why it is reload rather than restart are covered in nginx and ufw in front of your app.
A real n8n backup is the whole /home/node/.n8n directory (or a dump of the external database, if you switched to PostgreSQL instead of SQLite), not just the workflows themselves. Alongside the automation logic that directory holds encrypted connection credentials, execution history, and the encryption key: either the one you set with N8N_ENCRYPTION_KEY or the one n8n generated on its own the first time it started.
There is a separate CLI export path, useful for moving specific workflows between instances, but it is not a substitute for a real backup:
sudo docker compose exec n8n n8n export:workflow --backup --output=/home/node/.n8n/backup/workflows/
sudo docker compose exec n8n n8n export:credentials --backup --output=/home/node/.n8n/backup/credentials/
--backup is shorthand for exporting everything, pretty-printed, one file per item./home/node/.n8n volume. Point it anywhere else inside the container and the exported files disappear the next time the container is recreated, because that other path is not mounted anywhere.For an actual server backup plan, the whole volume, encrypted, stored off the box, running on a schedule, use the general method from how to back up your HIP server. For n8n specifically, the source for restic or a dump is the entire /opt/n8n/data directory, not the exported files.
sudo docker compose logs -n 50 n8n. File access errors almost always mean the data volume has the wrong ownership; repeat chown -R 1000:1000 /opt/n8n/data.N8N_WEBHOOK_URL but something like the container's own address, the variable was not picked up on the last restart. Confirm it actually reached the container: docker compose exec n8n env | grep WEBHOOK.N8N_PROTOCOL=https is in the compose file; nginx has proxy_set_header X-Forwarded-Proto $scheme; the compose file has N8N_PROXY_HOPS=1.docker compose config shows ./data:/home/node/.n8n pointing at where the files actually live, not at an empty directory created fresh by mistake.n8n.cloud is a managed plan with caps on active workflows, executions and seats, and it cannot reach a service that only lives inside your private network, only what is reachable from the internet. On your own VPS both limits disappear: workflows and executions are bounded only by the server, and an internal database or API is reachable directly. In exchange, image updates, backups and watching disk and memory become your responsibility.
n8n's documentation puts an idle instance at roughly 100 MB, with a typical range of 320 MB to 2 GB for ordinary workloads. A 1-2 GB plan is usually enough for personal or small-team automation. Heavy in-node data processing, many parallel executions, or an external PostgreSQL push the requirement noticeably higher.
N8N_PROTOCOL, N8N_HOST and N8N_PORT build the address n8n uses to describe itself, but behind a reverse proxy that address does not match the real public URL. The external address for webhooks is set separately through N8N_WEBHOOK_URL (the current name; WEBHOOK_URL is deprecated as of version 2.35.0), holding the full public HTTPS address. N8N_PROXY_HOPS=1 has to be set alongside it, or n8n misreads the request protocol from the proxy headers.
N8N_BASIC_AUTH_ACTIVE, N8N_BASIC_AUTH_USER and N8N_BASIC_AUTH_PASSWORD existed in older releases, but basic auth was removed from the product starting with n8n 1.0, and those variables now do nothing. The first time you open the interface, n8n prompts you to create an owner account with an email and password, and users get added to that account afterward with roles through the built-in user management.
Everything, workflows, encrypted connection credentials, execution history and the encryption key, lives in a SQLite file under /home/node/.n8n, which has to be mounted to the host. A real backup is that whole directory (or the external database, if you run PostgreSQL), because CLI-exported workflows and credentials skip users, execution history and the encryption key itself, without which restored connection credentials are unreadable.
Not to get started. n8n uses an embedded SQLite database inside its own data volume by default, which is enough for a single user or a small team. A dedicated PostgreSQL instance becomes worthwhile once several workers run in queue mode or the number of concurrent executions grows large enough that SQLite starts limiting writes.
/home/node/.n8n, mounted to the host. Skip the mount and the first docker compose down takes every workflow with it.n8n is one more Docker service on the same VPS, and the same perimeter and operational rules apply here as to the rest of a self-hosted stack. In order: