Tutorial

How to Self-Host n8n on a VPS

By Amith Kumar25 July 202614 min read
Self-hostingn8n
How to Self-Host n8n on a VPS
Verified 25 Jul 2026 · Ubuntu 24.04 LTS · Docker 29.1.3, nginx 1.24.0, PostgreSQL 16.14

What you will have

By the end of this guide you will self-host n8n on a VPS from nothing: a bare Ubuntu 24.04 slice becomes your own automation platform, running the same node-based workflow builder that teams pay a monthly per-execution bill for, except it runs in Docker on a box you own and the data stays with you. n8n connects APIs, databases, webhooks and schedules into workflows you draw on a canvas, and self-hosting it means no seat pricing, no per-run metering, and no third party holding the credentials your automations use.

That last point is the reason to bother. Your workflows carry API keys, database logins and customer data as they run, so where n8n lives is where those secrets live. Keeping it on a slice you control, on Indian hardware if that matters for you under the DPDP rules, puts that whole surface back in your hands.

Here is the destination. The screenshot below is the real canvas from the instance we stood up for this guide, a small "Daily site health check" workflow: a manual trigger, an HTTP request, and a node that shapes the result. Every screenshot in this guide is genuine, captured on that live box, so the version and the interface are what you will get.

sudo docker compose ps
docker compose ps on the finished install showing both containers up, the n8n container and its Postgres database, the whole automation platform running on the lab slice n8n.boxlab.space
The real n8n 2.31.6 editor canvas at n8n.boxlab.space over real HTTPS, the Daily site health check workflow with three nodes: a manual trigger, an HTTP Request node fetching servercake.in, and an Edit Fields node, the destination you are building toward

Two containers, n8n and its Postgres database, both up. That is the entire platform: no control panel to license, no runtime you do not own.

What this guide covers
  • Installing Docker and the Compose plugin on a bare Ubuntu 24.04 slice.
  • A Compose stack that runs n8n against Postgres, with named volumes and the key environment variables.
  • Putting n8n behind nginx over a real HTTPS certificate, with the websocket upgrade the editor needs.
  • Creating the owner account and building a first workflow that genuinely runs.
  • Keeping it running: restart policy, image updates, and backups of the database plus the encryption key.

Before you begin

This is a hands-on, build-it-yourself guide, and it takes a few things as given. None of them are heavy.

What you need first
  • An Ubuntu 24.04 LTS slice you can reach over SSH as a user with sudo. Two vCPUs and 2 GB of RAM is a sensible starting size for a personal instance.
  • A domain or subdomain you control, with access to edit its DNS. Most people give n8n its own subdomain like n8n.example.com.
  • Your slice's public IPv4 address, so you can point the subdomain's DNS record at it.
  • Roughly thirty minutes, and a habit of reading what each command reports before running the next.

Everything below runs as a normal sudo user over SSH. Docker commands use sudo because the daemon runs as root; if you have added your user to the docker group you can drop the sudo.

Install Docker and Compose

n8n is distributed as a Docker image, which is why this guide runs it in a container rather than from source. A container pins the exact runtime n8n expects, upgrades become a one-line image pull, and the whole stack, app plus database, is described in a single file you can keep in version control. If Docker is already on your slice you can skip this section; check with docker --version.

On a bare box, install Docker Engine and the Compose plugin from Docker's official repository. The convenience script does the repository setup and key handling for you.

curl -fsSL https://get.docker.com | sudo sh
docker --version
docker compose version
Installing Docker Engine and the Compose plugin from the official script on a bare Ubuntu 24.04 slice, then reading the versions back: Docker 29.1.3 and Docker Compose 2.40.3

Read both versions back so you know what you are running. On our lab slice this is Docker 29.1.3 and the Compose plugin 2.40.3. Compose is the piece that reads a docker-compose.yml and brings a multi-container stack up together, which is exactly what n8n plus a database is.

Checkpoint You should now get a version string from both docker --version and docker compose version. If docker compose reports "is not a docker command", you have the old standalone docker-compose binary; install the Compose plugin so the v2 syntax used below works.

Self-host n8n on a VPS with Docker and Postgres

n8n will run its own SQLite file if you let it, which is fine for a first look and a poor idea for anything you rely on. A real deployment uses Postgres: it handles concurrent writes, it backs up cleanly, and it does not corrupt under load the way a single file can. So the stack is two services, n8n and Postgres, wired together by Compose.

First, a working directory and two secrets. n8n encrypts the credentials your workflows store, and it does that with an encryption key. If that key ever changes, every saved credential becomes unreadable, so you generate it once, now, and treat it like a password. The database needs its own password too.

sudo mkdir -p /opt/n8n && cd /opt/n8n
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"   | sudo tee  .env
echo "N8N_ENCRYPTION_KEY=$(openssl rand -hex 24)"  | sudo tee -a .env
sudo chmod 600 .env
Generating the two secrets into a .env file with openssl, the Postgres password and the N8N_ENCRYPTION_KEY, both masked to placeholders and the file locked to mode 600

Those two values live in .env, which sits beside the Compose file and is read into it at run time. Keeping secrets in .env rather than hard-coded in docker-compose.yml means the compose file itself is safe to commit. Now the stack. Create docker-compose.yml in the same directory.

# /opt/n8n/docker-compose.yml
name: n8n

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: n8n
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
      interval: 5s
      timeout: 5s
      retries: 12

  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:5678:5678"
    environment:
      - N8N_HOST=n8n.example.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - N8N_EDITOR_BASE_URL=https://n8n.example.com/
      - WEBHOOK_URL=https://n8n.example.com/
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=db
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - GENERIC_TIMEZONE=Asia/Kolkata
      - N8N_PROXY_HOPS=1
      - N8N_RUNNERS_ENABLED=true
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      db:
        condition: service_healthy

volumes:
  db_data:
  n8n_data:

A few lines are worth understanding rather than copying blind. The port publishes n8n to 127.0.0.1:5678, loopback only, so n8n is never exposed to the internet directly; nginx will be the only thing in front of it. N8N_PROTOCOL=https and WEBHOOK_URL tell n8n it is served over HTTPS at your domain, so the links and webhook URLs it generates are correct. N8N_PROXY_HOPS=1 tells n8n it sits behind one reverse proxy, so it trusts the forwarded protocol header.

The two named volumes, db_data and n8n_data, are where everything durable lives, the database and n8n's own settings, so they survive a container restart or an image upgrade. Set N8N_HOST, N8N_EDITOR_BASE_URL and WEBHOOK_URL to your own subdomain. Then bring it up.

sudo docker compose up -d
docker compose up -d pulling the n8n and postgres:16-alpine images, creating the named volumes and network, waiting for Postgres to report healthy, then starting n8n

Compose pulls the two images, creates the network and volumes, waits for Postgres to report healthy, and only then starts n8n. Give it a moment, then confirm both containers are up and n8n is answering its health endpoint on loopback.

sudo docker compose ps
curl -s http://127.0.0.1:5678/healthz
Verifying the stack came up: docker compose ps showing both containers up with n8n published on 127.0.0.1:5678 only, and the health endpoint returning status ok

A {"status":"ok"} from the health endpoint means n8n is running and connected to Postgres. It is not reachable from outside yet, which is exactly what we want until HTTPS is in front of it.

nginx and real HTTPS

n8n is on the slice but only on loopback. To reach it from a browser you put nginx in front as a reverse proxy that terminates HTTPS and passes requests through to the container. The one detail people miss is that the n8n editor keeps a live websocket connection open for its push updates, so the proxy has to be told to upgrade that connection or the canvas will sit there loading forever.

In your DNS, add an A record for the hostname you want, pointing at your slice's public IP. Then add the websocket upgrade map, which belongs in the http context, in its own small file.

# /etc/nginx/conf.d/n8n-upgrade.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Now the server block. It listens on 443 with a real certificate and proxies everything to 127.0.0.1:5678, forwarding the headers n8n needs to know it is behind HTTPS, and upgrading the websocket.

# /etc/nginx/sites-available/n8n.example.com
server {
    listen 443 ssl http2;
    server_name n8n.example.com;

    ssl_certificate     /etc/letsencrypt/live/n8n.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/n8n.example.com/privkey.pem;

    add_header Strict-Transport-Security "max-age=15768000; includeSubDomains" always;
    client_max_body_size 32m;

    location / {
        proxy_pass http://127.0.0.1:5678;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}

Those ssl_certificate paths come from Certbot, which issues a free certificate that renews itself before it expires. Install it with sudo apt install -y certbot python3-certbot-nginx and run sudo certbot --nginx -d n8n.example.com to fill them in. Then enable the site, test the config, and reload.

sudo ln -s /etc/nginx/sites-available/n8n.example.com \
     /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx -t reporting the configuration test is successful, then openssl confirming the served certificate is a real Let's Encrypt wildcard covering the n8n subdomain

That nginx -t line is your safety check: nginx refuses to reload a config that does not parse, so run it every time before the reload. With the certificate in place, confirm the whole path end to end from the public side over HTTP/2.

curl --http2 -sI https://n8n.example.com/healthz
curl over HTTP/2 returning 200 from https://n8n.boxlab.space through nginx into the container, proving the full HTTPS path works over the real certificate

An HTTP/2 200 here means a browser request travels over a real certificate, through nginx, into the container, and back. The padlock is genuine, issued by a public authority and trusted by every browser.

Checkpoint You should now reach https://n8n.example.com in a browser and land on the n8n sign-in screen over a valid padlock. If the page loads but the editor never finishes, the websocket upgrade is missing; recheck the map block and the Connection and Upgrade proxy headers.

First run: your owner account

Open your domain in a browser. On a fresh instance n8n asks you to create the owner account, the first user, who administers everything. Set it up with your email and a strong password. You can do that in the browser, or drive it once from the shell against the same setup endpoint, which is handy when you script a rebuild.

curl -s https://n8n.example.com/rest/owner/setup \
     -H 'content-type: application/json' \
     -d '{"email":"[email protected]","firstName":"Site",
          "lastName":"Owner","password":"<a-strong-password>"}'
Creating the n8n owner account from the shell against the setup endpoint, with the email shown and the owner password masked to a placeholder
The genuine n8n sign-in page at n8n.boxlab.space served over a valid padlock, framed in the ServerCake browser chrome, the screen you and your team return to after the owner account is created

Once the owner exists, the sign-in screen is where you and your team come back to. From here it is your editor: a blank canvas waiting for its first workflow.

A first workflow that runs

The payoff is a workflow that actually does something, so build a small but genuine one: a daily check that a website is reachable. It is three nodes. A manual trigger to start it on demand, an HTTP Request node that fetches a URL, and an Edit Fields node that turns the raw response into a tidy result. On the canvas, add the nodes in that order and connect them left to right:

  • When clicking 'Execute workflow', the manual trigger, so you can run it yourself while building.
  • HTTP Request, method GET, URL https://servercake.in, with "full response" turned on so the status code comes through.
  • Edit Fields, setting site, http_status from the response code, and result to reachable when the status is 200.

Click Execute workflow. n8n runs the nodes left to right, and each one gets a green check as it passes data on. That is a real HTTP request leaving your slice, hitting the site, and its status flowing into the final node. Swap the URL for your own site and you have a genuine uptime check; add a Schedule Trigger in place of the manual one and it runs every morning on its own.

Every run is recorded. n8n stores its executions in Postgres, so you can confirm the run landed by asking the database directly.

sudo docker compose exec db \
     psql -U n8n -d n8n -c \
     "select id, status, mode from execution_entity order by id desc limit 3;"
Confirming the workflow ran by querying the Postgres execution_entity table directly: the newest row shows status success and mode manual, proof the execution was saved
The successful execution on the n8n canvas at n8n.boxlab.space: all three nodes green with checkmarks and one item flowing between them, the Executions panel showing Succeeded in 531ms, proof the workflow genuinely ran

A row with status = success is your proof: the workflow ran, end to end, and n8n saved the result. The Executions tab in the editor shows the same run, "Succeeded", with the time it took and every node green.

Do not skip this Back up N8N_ENCRYPTION_KEY the day you create it. Restore the database without that exact key and every stored credential is unreadable, which turns a five-minute restore into a rebuild of every connection by hand.

Keep it running and updated

Because the whole stack is described by the Compose file plus .env plus two named volumes, running it for the long term is straightforward. The restart: unless-stopped policy you set means Docker brings both containers back after a reboot or a crash, on its own. Confirm the policy is in force.

sudo docker inspect -f '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' \
     n8n-n8n-1 n8n-db-1

Updating n8n is a pull and a recreate. Compose fetches the newer image and replaces only the containers whose image changed; the volumes, and so your workflows and credentials, are untouched.

sudo docker compose pull
sudo docker compose up -d

Backups are the habit that matters most, and there are exactly two things to capture: the Postgres database, which holds your workflows and execution history, and the N8N_ENCRYPTION_KEY, which the database is useless without. Dump the database from its container and keep the key beside it, then copy both off the slice.

sudo docker compose exec -T db pg_dump -U n8n n8n \
     | gzip > n8n-db-$(date +%F).sql.gz
sudo grep N8N_ENCRYPTION_KEY /opt/n8n/.env > n8n-key.txt
Backing up the two things that matter: pg_dump of the n8n Postgres database piped to a gzip archive, and the N8N_ENCRYPTION_KEY copied out beside it, both to be moved off the slice

Put those two files somewhere off the machine, because a backup that lives on the same slice is not a backup. That is a complete, hand-built n8n install: Docker and Compose, a Postgres-backed stack bound to loopback, real HTTPS through nginx with the websocket upgrade, an owner account, a workflow that ran, and a backup you can actually restore from.

Two follow-ups turn this into a production setup. The first is running n8n in queue mode with separate worker containers so it survives real load and restarts cleanly, which is worth doing once your workflows carry real traffic. The second is hardening the instance, tightening authentication, the firewall, and how you expose webhooks safely. Both build directly on the stack you have here.

Frequently asked questions

Do I need Postgres, or is the default SQLite enough to self-host n8n on a VPS? SQLite is fine to try n8n and a poor choice for anything you depend on. It is a single file, so it handles concurrent writes badly and can corrupt under load or a bad shutdown, and it does not back up as cleanly as a real database. Postgres, as set up here, handles concurrency, backs up with a single pg_dump, and is the configuration the n8n project itself recommends for production. Adding it costs one extra container in the same Compose file, so there is little reason to run a real instance on SQLite.
Why does the n8n editor load but never finish, or the canvas hang? Almost always the missing websocket upgrade in the reverse proxy. The n8n editor holds a live push connection open for its updates, and if nginx does not upgrade that connection the editor loads the shell and then waits forever. The fix is the map $http_upgrade $connection_upgrade block plus the Upgrade and Connection headers on the proxy, both shown above. Check them first when the interface will not settle.
How much VPS do I need for a personal n8n instance? Two vCPUs and 2 GB of RAM is a comfortable starting point for one person's workflows, which is why this guide targets roughly that. What grows the requirement is not idle n8n but heavy workflows: large data transformations, many workflows running at once, or memory-hungry nodes. Watch memory and execution times, and size the slice up only when the numbers say so. Storage is the resource you are most likely to add first, since the database and execution history grow over time.
What exactly do I back up, and how do I restore? Two things: the Postgres database and the N8N_ENCRYPTION_KEY. The database holds your workflows, credentials and execution history; the key is what those credentials are encrypted with. Back both up together, because the database is unreadable without the matching key. To restore, bring up a fresh stack with the same key in .env, then load the dump into Postgres with psql. Restoring the database while pointing at a different key leaves every stored credential unreadable, so the key is the part not to lose.

Running n8n on your own slice means you own the whole automation platform: the workflows, the credentials they carry, and the bill, with no per-execution metering and no black box. To put it into service, take a ServerCake slice, a fresh Ubuntu 24.04 box hosted in India and billed in rupees with GST, and follow the steps above on hardware that answers only to you.


Run your own automation platform on a slice

Self-host n8n on a slice

Your workflows carry the API keys and data they run on, so they belong on a box you own, not a metered platform. Spin up a ServerCake slice, a clean Ubuntu 24.04 box in India billed in rupees with GST, and run n8n in Docker exactly as in this tutorial, from the Compose stack to a workflow that runs.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot