This guide sets up self-hosted n8n for light production use on an Ubuntu 24.04 VPS: n8n backed by PostgreSQL, the task runner in its own container (which is what the n8n docs recommend for production), and Caddy in front to issue and renew the HTTPS certificate. The whole stack runs on Docker Compose from three files, and the guide also covers updates and backups.
What you need
- A VPS running 64-bit Ubuntu 24.04, with SSH access as a user that has
sudo. - A domain where you can create DNS records. The examples use
n8n.example.com. - An email address for the certificate's ACME account.
The n8n docs don't set a hardware minimum for this exact setup. The official Docker Compose guide asks for 2 vCPUs and 4 GB of RAM once you add the n8n Assistant sandbox. This stack runs four containers (Caddy, n8n, the runner and PostgreSQL), so treat 2 vCPUs and 4 GB as the floor. With 4 vCPUs and 8 GB you have room for executions that move more data and for the database to grow.
If you just want to try n8n, Nuvits can also deliver the VPS with n8n preinstalled when you order it. That option runs n8n in a single container, without PostgreSQL or an external runner, and it only answers inside the VPS: you open the editor through an SSH tunnel. To receive webhooks from the internet with a domain and HTTPS, follow this guide.
Prepare the server
If your provider hands you the VPS with root access, create a user with sudo and copy your SSH key over:
adduser deploy usermod -aG sudo deploy install -d -m 700 -o deploy -g deploy /home/deploy/.ssh install -m 600 -o deploy -g deploy ~/.ssh/authorized_keys /home/deploy/.ssh/ Open a second session as deploy and make sure you can log in before closing the root one. Then allow SSH, HTTP and HTTPS in ufw and turn the firewall on:
sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443 sudo ufw enable sudo ufw status ufw enable asks for confirmation because it may drop the SSH connection; with the port 22 rule already in place, answer y. The 443 rule has no protocol, so it covers both TCP and UDP. UDP is for HTTP/3, which Caddy also serves.
Docker bypasses ufw. A port published with
ports:in Compose is reachable from outside even without a ufw rule, because Docker routes that traffic before it reaches the firewall's rules. That's why only Caddy publishes ports (80 and 443) in this guide. n8n and PostgreSQL stay on the internal Compose network.
Install Docker
At Nuvits you can have Docker preinstalled when you order the VPS. If that's your case, skip ahead to docker compose version at the end of this section.
For servers, the Docker docs point to the official apt repository. First remove any unofficial packages that could conflict (on a fresh install, apt will usually report there's nothing to remove):
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1) Add Docker's GPG key and repository:
sudo apt update sudo apt install 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 sudo tee /etc/apt/sources.list.d/docker.sources <<EOF Types: deb URIs: https://download.docker.com/linux/ubuntu Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") Components: stable Architectures: $(dpkg --print-architecture) Signed-By: /etc/apt/keyrings/docker.asc EOF sudo apt update Install Docker Engine and the Compose plugin, then check that everything works:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo docker run hello-world docker compose version There's also the get.docker.com convenience script, but Docker's own docs recommend it only for testing and development, not production.
The commands in this guide use sudo docker. You can add your user to the docker group to drop the sudo, but the Docker docs warn that the group grants root-level privileges.
Configure n8n
Create a project directory. Compose uses its name as the prefix for volumes and containers:
mkdir -p ~/n8n && cd ~/n8n Secrets in .env
Generate three random values: one for the encryption key, one for the runner token and one for the database password.
openssl rand -hex 32 Run it three times and paste the results into .env:
N8N_VERSION=2.47.4 N8N_DOMAIN=n8n.example.com ACME_EMAIL=you@example.com GENERIC_TIMEZONE=America/New_York N8N_ENCRYPTION_KEY=paste-the-first-value-here N8N_RUNNERS_AUTH_TOKEN=paste-the-second-value-here POSTGRES_USER=n8n POSTGRES_PASSWORD=paste-the-third-value-here POSTGRES_DB=n8n chmod 600 .env N8N_VERSION pins both the n8n and the runner images, which must run the same version. Use the latest stable release listed on n8n's releases page; 2.47.4 was the stable version when this was written. Set GENERIC_TIMEZONE to your own IANA time zone name, such as Europe/Berlin or America/Sao_Paulo.
Keep the key safe. n8n uses
N8N_ENCRYPTION_KEYto encrypt the credentials it stores in the database. Without it, a restored backup will load, but none of its credentials can be decrypted. Keep a copy of.envsomewhere off the VPS, such as a password manager.
Caddyfile
Caddy gets the certificate on its own as soon as it sees a domain name in the site address. flush_interval -1 turns off response buffering, as in n8n's official Caddy example:
{ email {$ACME_EMAIL} } {$N8N_DOMAIN} { reverse_proxy n8n:5678 { flush_interval -1 } } Caddy fills in {$N8N_DOMAIN} and {$ACME_EMAIL} from the container's environment, which Compose sets from .env. reverse_proxy already sends the X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers that n8n needs behind a proxy.
docker-compose.yml
services: caddy: image: caddy:2.11 restart: unless-stopped ports: - "80:80" - "443:443" - "443:443/udp" environment: - N8N_DOMAIN=${N8N_DOMAIN} - ACME_EMAIL=${ACME_EMAIL} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: - n8n postgres: image: postgres:18 restart: always environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} - PGDATA=/var/lib/postgresql/data volumes: - db_storage:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 10 n8n: image: docker.n8n.io/n8nio/n8n:${N8N_VERSION} restart: always environment: - N8N_HOST=${N8N_DOMAIN} - N8N_PORT=5678 - N8N_PROTOCOL=https - N8N_WEBHOOK_URL=https://${N8N_DOMAIN}/ - N8N_PROXY_HOPS=1 - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true - GENERIC_TIMEZONE=${GENERIC_TIMEZONE} - TZ=${GENERIC_TIMEZONE} - DB_TYPE=postgresdb - DB_POSTGRESDB_HOST=postgres - DB_POSTGRESDB_PORT=5432 - DB_POSTGRESDB_DATABASE=${POSTGRES_DB} - DB_POSTGRESDB_USER=${POSTGRES_USER} - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD} - N8N_RUNNERS_MODE=external - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN} - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 volumes: - n8n_data:/home/node/.n8n depends_on: postgres: condition: service_healthy n8n-runner: image: n8nio/runners:${N8N_VERSION} restart: always environment: - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN} - N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679 depends_on: - n8n volumes: caddy_data: caddy_config: db_storage: n8n_data: A few things worth knowing about this file:
-
N8N_WEBHOOK_URLandN8N_PROXY_HOPS=1make n8n build webhook URLs with the public domain and trust Caddy's forwarded headers. Without them, the editor shows URLs with the internal port 5678. Before n8n 2.30.0 the variable was calledWEBHOOK_URL, which is now deprecated. - The external runner executes Code node scripts outside the n8n process. Internal mode, the default, is deprecated, and the docs advise against it in production: code that escapes the sandbox gets the same access as n8n, credentials included.
- Pinning
PGDATAkeeps Postgres 18's data inside the volume. Don't remove that line. Without it, Postgres 18 writes outside the volume and the database starts empty. - n8n keeps its volume at
/home/node/.n8neven with PostgreSQL. The docs recommend keeping that folder persistent. - Neither n8n nor Postgres publishes a port. Caddy reaches n8n by its service name on the Compose network.
Point DNS and start
In your domain's DNS settings, create an A record named n8n (or whatever subdomain you picked) pointing to the VPS's IPv4 address. Before starting the stack, check that the name already resolves to the right IP:
dig +short n8n.example.com A Once DNS resolves, bring the stack up:
cd ~/n8n sudo docker compose up -d sudo docker compose ps n8n only starts after Postgres passes its healthcheck. Follow the n8n and Caddy logs until the editor is up and the certificate has been issued:
sudo docker compose logs -f n8n sudo docker compose logs caddy If the certificate doesn't come through, check that DNS has propagated and that ports 80 and 443 are also open in your provider's firewall, if it has one. Once that's fixed, run sudo docker compose restart caddy.
Then test from outside the VPS:
curl -fsS https://n8n.example.com/healthz Open https://n8n.example.com in your browser and create the owner account. To check webhooks, add a Webhook node to a workflow: its production URL should start with https://n8n.example.com/webhook/.
Update and back up
Backups
A full backup of n8n on PostgreSQL has two parts: the database, and the .n8n folder that holds the configuration and the encryption key. With the stack running:
cd ~/n8n set -a && . ./.env && set +a sudo docker compose exec -T postgres pg_dumpall -U "$POSTGRES_USER" > n8n-db-$(date +%F).sql grep -c "PostgreSQL database cluster dump complete" n8n-db-$(date +%F).sql sudo docker run --rm --volumes-from "$(sudo docker compose ps -q n8n)" -v "$PWD":/backup ubuntu:24.04 tar czf /backup/n8n-data-$(date +%F).tar.gz /home/node/.n8n The grep must print 1. If it prints 0, the dump is incomplete. Then, from your own machine, copy both files and .env off the VPS:
mkdir -p backups-n8n scp 'deploy@VPS_IP:n8n/n8n-*' 'deploy@VPS_IP:n8n/.env' ./backups-n8n/ A backup that only lives on the VPS's disk is gone if you lose the VPS. To restore, stop n8n, put back the .n8n folder and the database, and start again with the same .env. The Back up and restore page walks through each step.
Never run
docker compose down -vin this directory. The-valso deletes the volumes, includingn8n_datawith the encryption key anddb_storagewith the database.
Updates
n8n ships a new minor version most weeks. The docs suggest updating at least once a month, so you don't have to jump several versions at once, and reading the release notes first. The routine:
- Take the backup above.
- Change
N8N_VERSIONin.envto the newstableversion. The runner follows along, since it uses the same variable. - Pull the images and recreate the containers:
cd ~/n8n sudo docker compose pull sudo docker compose down sudo docker compose up -d sudo docker compose logs -f n8n n8n runs its database migrations on startup. Switching the tag back doesn't undo them, which is why the backup comes first. docker compose pull also fetches the latest Caddy 2.11 and PostgreSQL 18 images. Moving Postgres to a new major version (18 to 19, for example) takes a dump and restore, not just a tag change.
Next steps
- Turn on two-factor authentication for the owner account and set up SMTP for invites and password resets, using the user management variables.
- Schedule the backup with
cronand ship the copies somewhere else automatically. - Give n8n its own Postgres user without superuser rights, as in the official withPostgres example.
- Read the task runner hardening guide if more people will be editing workflows on the instance.

