n8n is a workflow automation tool, similar to Zapier or Make, that connects APIs and services through a visual editor and can also run custom JavaScript or Python code. In this tutorial you will self-host n8n on Ubuntu 24.04 with Docker Compose, using PostgreSQL as the database, and publish it through Nginx with a Let's Encrypt certificate. At the end you will build a small webhook workflow and call it with curl to confirm everything works end to end.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM and 20 GB of free disk space.
- A non-root user with
sudoprivileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository.
docker compose versionmust print a v2 version. - A domain or subdomain (this guide uses
n8n.your_domain) with a DNSArecord pointing toyour_server_ip.
Step 1 - Creating the project directory and secrets
Keep the whole deployment in one directory so it is easy to back up and move:
sudo mkdir -p /opt/n8n
sudo chown $USER:$USER /opt/n8n
cd /opt/n8n
n8n encrypts the credentials you store (API keys, OAuth tokens) with an encryption key. If you lose that key, stored credentials become unreadable, so generate it yourself and keep it in an .env file together with the database password:
cat > .env <<EOF
N8N_DOMAIN=n8n.your_domain
POSTGRES_PASSWORD=$(openssl rand -hex 24)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
GENERIC_TIMEZONE=Europe/Madrid
EOF
chmod 600 .env
Replace n8n.your_domain with your domain and Europe/Madrid with your time zone (timedatectl list-timezones lists valid names). Check the file:
cat .env
N8N_DOMAIN=n8n.your_domain
POSTGRES_PASSWORD=5d0b6f3c1e...
N8N_ENCRYPTION_KEY=9a41c7e2f0...
GENERIC_TIMEZONE=Europe/Madrid
Save a copy of N8N_ENCRYPTION_KEY in your own password manager.
Step 2 - Writing the Docker Compose file
Create the Compose file:
nano /opt/n8n/compose.yaml
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: n8n
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
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}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: ${N8N_DOMAIN}
N8N_PORT: 5678
N8N_PROTOCOL: https
WEBHOOK_URL: https://${N8N_DOMAIN}/
N8N_PROXY_HOPS: 1
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
TZ: ${GENERIC_TIMEZONE}
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
n8n_data:
The important settings are:
DB_TYPE: postgresdbswitches n8n from its default SQLite file to PostgreSQL.- The port is published only on
127.0.0.1, so n8n is reachable from the Internet only through Nginx. Docker bypasses UFW for published ports, which is why binding to localhost matters. WEBHOOK_URLandN8N_PROTOCOLmake n8n generate publichttps://webhook URLs instead ofhttp://localhost:5678.N8N_PROXY_HOPS: 1tells n8n it sits behind one reverse proxy, so it trusts theX-Forwarded-*headers from Nginx.
Validate the file; Compose prints an error if the YAML or a variable is wrong:
docker compose config --quiet && echo "compose.yaml OK"
compose.yaml OK
Step 3 - Starting n8n
Pull the images and start both containers in the background:
docker compose up -d
Check their state. PostgreSQL must be healthy before n8n starts:
docker compose ps
NAME IMAGE SERVICE STATUS PORTS
n8n-n8n-1 docker.n8n.io/n8nio/n8n n8n Up 20 seconds 127.0.0.1:5678->5678/tcp
n8n-postgres-1 postgres:16 postgres Up 31 seconds (healthy) 5432/tcp
Follow the n8n log until it reports that the editor is available, then press Ctrl+C:
docker compose logs -f n8n
n8n-1 | Editor is now accessible via:
n8n-1 | https://n8n.your_domain
Finally, confirm that it answers locally:
curl -s http://127.0.0.1:5678/healthz
{"status":"ok"}
Step 4 - Configuring Nginx and HTTPS
Install Nginx and Certbot and open the web ports:
sudo apt install nginx certbot python3-certbot-nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
The n8n editor keeps a persistent WebSocket connection to receive execution updates, so the proxy must pass the Upgrade headers. Create the server block:
sudo nano /etc/nginx/sites-available/n8n
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name n8n.your_domain;
client_max_body_size 50M;
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-Real-IP $remote_addr;
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;
}
}
proxy_buffering off lets streamed responses reach the browser immediately, and the long read timeout keeps long-running webhook calls from being cut. Enable the site and reload Nginx:
sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Request the certificate. Certbot adds the TLS settings and an HTTP to HTTPS redirect to this server block:
sudo certbot --nginx -d n8n.your_domain
Check the result from outside the proxy:
curl -s https://n8n.your_domain/healthz
{"status":"ok"}
Renewal is handled by the certbot.timer systemd timer; sudo certbot renew --dry-run confirms it works.
Step 5 - Creating the owner account
Open https://n8n.your_domain. On first launch n8n asks you to set up the owner account: enter your email, name and a strong password. This account manages users and instance settings. Signing up for the free community features that n8n offers afterwards is optional.
You now see the workflow overview, which is empty.
Step 6 - Building and testing a webhook workflow
To verify that public webhooks work through the proxy, build a two-node workflow:
- Click Create Workflow.
- Click Add first step, search for Webhook and add it. Set HTTP Method to
POST, set Path tohello, and set Respond to Using 'Respond to Webhook' Node. - Click the + on the right of the Webhook node, add Respond to Webhook, set Respond With to JSON, and enter this as the response body:
{ "received": "{{ $json.body.name }}" }
- Save the workflow and switch it to Active with the toggle at the top.
The Webhook node shows two URLs: a Test URL under /webhook-test/ that only works while you click Listen for test event in the editor, and a Production URL under /webhook/ that works while the workflow is active. Call the production URL:
curl -s -X POST https://n8n.your_domain/webhook/hello \
-H 'Content-Type: application/json' \
-d '{"name":"CubePath"}'
{"received":"CubePath"}
Open Executions in the workflow to see the run with the input and output of each node. From here you can replace the Respond node with real integrations: store credentials under Credentials and n8n encrypts them with your N8N_ENCRYPTION_KEY.
Step 7 - Backing up n8n
All workflows, credentials and execution history live in PostgreSQL. Dump the database into a dated, compressed file:
sudo install -d -m 700 -o $USER /var/backups/n8n
cd /opt/n8n
docker compose exec -T postgres pg_dump -U n8n n8n | gzip > /var/backups/n8n/n8n-db-$(date +%F).sql.gz
ls -lh /var/backups/n8n
Also keep a copy of /opt/n8n/.env and /opt/n8n/compose.yaml. A database dump restored with a different N8N_ENCRYPTION_KEY loses access to every stored credential.
For a portable export of workflows as JSON files, which you can import into another n8n instance or keep in Git, use the n8n CLI inside the container:
docker compose exec -u node n8n n8n export:workflow --all --output=/home/node/.n8n/workflows.json
docker compose cp n8n:/home/node/.n8n/workflows.json /var/backups/n8n/workflows-$(date +%F).json
Step 8 - Updating n8n
Before updating, take a database dump as in Step 7 and read the release notes for breaking changes. Then pull the new image and recreate the container:
cd /opt/n8n
docker compose pull
docker compose up -d
docker image prune -f
n8n runs its database migrations on startup. Watch docker compose logs -f n8n until the editor is available again. To stay on a specific release instead of the latest one, pin a version tag in compose.yaml (for example docker.n8n.io/n8nio/n8n:<version>, using a version number from the n8n release notes).
Troubleshooting
- Webhook URLs show
http://localhost:5678:WEBHOOK_URLis missing or wrong incompose.yaml. Fix it and rundocker compose up -d. - Editor shows "Connection lost": WebSockets are not reaching n8n. Check the
mapblock and theUpgrade/Connectionheaders in the Nginx server block. - n8n restarts with a "Mismatching encryption keys" error: the key in
.envdiffers from the one saved in then8n_datavolume. Restore the original key. - Permission errors on
/home/node/.n8n: happens when a host directory is mounted instead of a named volume. The container runs as UID 1000, so the directory must be owned by1000:1000.
Conclusion
You now have n8n running on Ubuntu 24.04 with PostgreSQL, published over HTTPS through Nginx, with working production webhooks and a backup and update routine. As next steps, invite team members from Settings > Users, connect your first real integration such as Slack or Google Sheets, and schedule the backup commands with a systemd timer or cron.
