To self-host n8n with Docker Compose on Ubuntu 24.04, run n8n with persistent storage and PostgreSQL, keep port 5678 bound to localhost, and publish the instance through an HTTPS reverse proxy. This tutorial builds that production-shaped baseline with Nginx, safe firewall handling, execution pruning, backups, and restore verification.
Raff Technologies is used as the Ubuntu VM platform in the saved test environment. If you want the faster Raff path instead of operating the Compose stack manually, use the n8n Marketplace deployment. If you want full control over the containers, database, proxy, and recovery process, continue with this tutorial on a Raff Linux VM.
As of September 7, 2026, the current stable n8n release is 2.37.10. n8n 2.38.3 is available as a pre-release, so this tutorial pins the stable 2.37.10 image instead of following a pre-release tag. The previous Raff workflow was reviewed on Ubuntu 24.04 in July 2026; the current n8n 2.37.10 configuration and documentation were re-verified for this refresh without claiming a new full machine retest.
Important n8n 2.x change: use
N8N_WEBHOOK_URLbehind a reverse proxy. The olderWEBHOOK_URLvariable is deprecated. n8n 2.x also uses task runners by default in internal mode, so you do not need the deprecatedN8N_RUNNERS_ENABLED=truesetting for this deployment.
Prerequisites:
- An Ubuntu 24.04 VM with SSH access through a non-root sudo user
- Docker Engine and the Docker Compose plugin; see Install Docker on Ubuntu 24.04
- A domain such as
n8n.example.compointed to the VM public IPv4 address - Ports
80/tcpand443/tcpavailable for Nginx - A password manager or another protected place for the PostgreSQL password and n8n encryption key
- A recovery path before changing firewall rules
Step 1 — Verify Ubuntu, DNS, and Docker
Update package metadata and install the host utilities used later:
sudo apt update sudo apt upgrade -y sudo apt install -y curl ca-certificates openssl nginx ufw snapd dnsutils
If /var/run/reboot-required exists, reboot during a safe maintenance window before continuing:
test -f /var/run/reboot-required && cat /var/run/reboot-required
Verify Ubuntu, Docker, and Compose:
lsb_release -ds docker --version docker compose version
Confirm the domain resolves to this server:
dig +short A n8n.example.com
Compare the returned address with the VM public IPv4 address in the Raff dashboard.
Verify: Ubuntu should report 24.04 LTS, both Docker commands should succeed, and the n8n hostname should resolve to the intended VM before you request a public certificate.
Step 2 — Create the n8n project and backup directories
Create a dedicated project directory:
sudo mkdir -p /opt/n8n/backups sudo chown -R "$USER":"$USER" /opt/n8n cd /opt/n8n
Check ownership:
ls -ld /opt/n8n /opt/n8n/backups
Verify: both directories should exist and be writable by the administrative user managing this deployment.
Step 3 — Create protected n8n secrets and environment settings
Generate a strong PostgreSQL password and a separate n8n encryption key:
openssl rand -base64 36 openssl rand -hex 32
Store both values in a password manager. The exact n8n encryption key is required to decrypt saved credentials after a restore.
Create /opt/n8n/.env:
nano /opt/n8n/.env
Add:
N8N_VERSION=2.37.10 N8N_DOMAIN=n8n.example.com GENERIC_TIMEZONE=UTC POSTGRES_DB=n8n POSTGRES_USER=n8n POSTGRES_PASSWORD=REPLACE_WITH_A_LONG_RANDOM_DATABASE_PASSWORD N8N_ENCRYPTION_KEY=REPLACE_WITH_THE_64_CHARACTER_HEX_VALUE
Protect the file:
chmod 600 /opt/n8n/.env stat -c '%a %n' /opt/n8n/.env
Expected mode:
600 /opt/n8n/.env
Do not print .env into public logs, tickets, screenshots, or shell transcripts shared with other people.
Verify: .env should have mode 600, the database password and encryption key should be different values, and a protected off-server copy of the encryption key should exist.
Step 4 — Create the n8n Docker Compose stack
Create /opt/n8n/compose.yaml:
nano /opt/n8n/compose.yaml
Add:
name: n8n services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 10 n8n: image: docker.n8n.io/n8nio/n8n:${N8N_VERSION} restart: unless-stopped depends_on: postgres: condition: service_healthy ports: - "127.0.0.1:5678:5678" environment: 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_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY} N8N_HOST: ${N8N_DOMAIN} N8N_PORT: 5678 N8N_PROTOCOL: https N8N_EDITOR_BASE_URL: https://${N8N_DOMAIN}/ N8N_WEBHOOK_URL: https://${N8N_DOMAIN}/ N8N_PROXY_HOPS: 1 N8N_SECURE_COOKIE: "true" N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true" GENERIC_TIMEZONE: ${GENERIC_TIMEZONE} TZ: ${GENERIC_TIMEZONE} EXECUTIONS_DATA_PRUNE: "true" EXECUTIONS_DATA_MAX_AGE: 168 EXECUTIONS_DATA_PRUNE_MAX_COUNT: 10000 volumes: - n8n_data:/home/node/.n8n volumes: postgres_data: name: n8n_postgres_data n8n_data: name: n8n_data
This stack deliberately binds n8n to 127.0.0.1:5678. The application port is not a public service; Nginx will be the public HTTPS entry point.
n8n supports SQLite by default and PostgreSQL as an alternative database. This tutorial uses PostgreSQL because it gives the deployment an explicit database boundary and a straightforward pg_dump recovery workflow. PostgreSQL 16 remains an actively maintained branch and is a conservative container choice for this tutorial.
Validate the resolved Compose configuration:
cd /opt/n8n docker compose config >/tmp/n8n-compose-rendered.yaml
Confirm the services are present without printing resolved secrets:
grep -E '^ (n8n|postgres):$' /tmp/n8n-compose-rendered.yaml
Remove the temporary rendered configuration because it contains resolved environment values:
rm -f /tmp/n8n-compose-rendered.yaml
Verify: docker compose config should exit successfully and the rendered configuration should contain both postgres and n8n services.
Step 5 — Start PostgreSQL and n8n
Start the stack:
cd /opt/n8n docker compose up -d
Check container state:
docker compose ps
Review recent logs without printing environment files:
docker compose logs --tail=100 postgres docker compose logs --tail=100 n8n
Verify the running n8n version:
docker compose exec n8n n8n --version
Expected version:
2.37.10
Test the health endpoint locally:
curl -i http://127.0.0.1:5678/healthz
Confirm the application port is loopback-only:
ss -lntp | grep ':5678'
Expected listener includes:
127.0.0.1:5678
Verify: PostgreSQL should become healthy, n8n should run version 2.37.10, /healthz should respond locally, and port 5678 should not listen on 0.0.0.0.
Step 6 — Configure Nginx for n8n and forwarded headers
Create the Nginx server block:
sudo nano /etc/nginx/sites-available/n8n.example.com
Add:
server { listen 80; 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-Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_buffering off; proxy_read_timeout 300s; } }
n8n's current reverse-proxy guidance requires the last proxy in the request path to forward X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto. Because this tutorial has one Nginx proxy, the Compose configuration sets N8N_PROXY_HOPS=1.
Enable the site:
sudo ln -sfn /etc/nginx/sites-available/n8n.example.com \ /etc/nginx/sites-enabled/n8n.example.com sudo nginx -t sudo systemctl reload nginx
Test the HTTP route before requesting TLS:
curl -I http://n8n.example.com
Verify: nginx -t should succeed and the hostname should reach Nginx while n8n itself remains bound only to localhost.
Step 7 — Configure UFW without risking SSH lockout, then enable HTTPS
First inspect the firewall instead of enabling it blindly:
sudo ufw status verbose
Confirm the actual SSH listening port:
sudo sshd -T | awk '/^port / {print $2}'
If UFW is already active, preserve the existing SSH rule and allow the Nginx web profile:
sudo ufw allow 'Nginx Full' sudo ufw status numbered
If UFW is inactive, add a rule for the actual SSH port first. When SSH uses the normal port and the OpenSSH application profile matches your configuration:
sudo ufw allow OpenSSH
If SSH uses a custom port, allow that port instead, for example:
sudo ufw allow 2222/tcp
Then allow Nginx:
sudo ufw allow 'Nginx Full'
Before enabling UFW from a remote server, open a second SSH session and confirm the allowed SSH path is correct. Only then enable the firewall:
sudo ufw enable sudo ufw status numbered
Do not create a public allow rule for 5678/tcp.
Install Certbot using the snap package:
sudo snap install --classic certbot sudo ln -sf /snap/bin/certbot /usr/local/bin/certbot
Request the certificate and redirect HTTP to HTTPS:
sudo certbot --nginx --redirect -d n8n.example.com
Verify HTTPS and renewal:
curl -I https://n8n.example.com sudo certbot renew --dry-run
Verify: the original and second SSH sessions should remain usable, HTTPS should succeed, renewal simulation should pass, and port 5678 should still have no public firewall rule.
Step 8 — Create the owner account and verify public n8n URLs
Open:
https://n8n.example.com
Create the first owner account using a unique password.
Create a small workflow with a Manual Trigger and Edit Fields node, execute it, and confirm the result appears in the Executions view.
Add a Webhook node and inspect both its test and production URLs. They should use the public HTTPS hostname rather than localhost, a container name, or port 5678.
Verify the non-secret public URL settings inside the container:
cd /opt/n8n docker compose exec n8n env | \ grep -E '^(N8N_HOST|N8N_PROTOCOL|N8N_EDITOR_BASE_URL|N8N_WEBHOOK_URL|N8N_PROXY_HOPS)='
Expected values include:
N8N_HOST=n8n.example.com N8N_PROTOCOL=https N8N_EDITOR_BASE_URL=https://n8n.example.com/ N8N_WEBHOOK_URL=https://n8n.example.com/ N8N_PROXY_HOPS=1
Verify: the editor should load over HTTPS, a manual workflow should execute, and generated webhook URLs should use the public HTTPS domain.
Step 9 — Run the built-in n8n security audit
Run the n8n audit command:
cd /opt/n8n docker compose exec n8n n8n audit
The security audit can report risks involving credentials, database usage, filesystem nodes, community/custom nodes, and instance settings. Treat the report as a review tool, not as a guarantee that the instance is secure.
For production workflows:
- remove unused credentials;
- review community nodes before installation;
- restrict who can edit workflows that can change customer, billing, infrastructure, or security state;
- keep webhook authentication and authorization appropriate to the workflow;
- use private network paths for internal services where possible.
Verify: the audit should run successfully and each reported risk should be reviewed, accepted, or assigned for remediation.
Step 10 — Configure execution retention and create recoverable backups
The Compose configuration keeps pruning enabled and sets a seven-day age limit:
EXECUTIONS_DATA_PRUNE=true EXECUTIONS_DATA_MAX_AGE=168 EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
n8n currently enables execution pruning by default. Its documented defaults are 336 hours for age and 10,000 executions for count; this tutorial intentionally reduces the age window to seven days. Increase or decrease it only after deciding how much execution history you actually need.
Load the protected environment only in the current shell:
cd /opt/n8n set -a source .env set +a
Check PostgreSQL size and host storage:
docker compose exec -T postgres \ psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ -c "SELECT pg_size_pretty(pg_database_size(current_database()));" docker system df df -h /
Create a PostgreSQL dump:
DUMP="backups/n8n-db-$(date +%F-%H%M%S).sql" docker compose exec -T postgres \ pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ > "$DUMP"
Verify that the dump is not empty:
test -s "$DUMP" && echo "PostgreSQL dump created: $DUMP"
For a consistent archive of the n8n persistent volume, stop the n8n application container during a short maintenance window while leaving PostgreSQL running:
docker compose stop n8n
Archive the volume:
docker run --rm \ -v n8n_data:/data:ro \ -v /opt/n8n/backups:/backup \ alpine \ tar -czf "/backup/n8n-data-$(date +%F-%H%M%S).tar.gz" -C /data .
Start n8n again:
docker compose start n8n curl -fsS http://127.0.0.1:5678/healthz
Also protect compose.yaml, .env, and the exact encryption key in an off-server backup location. A database dump without the matching encryption key is not a complete credential recovery plan.
n8n documents external S3 binary storage as a Self-hosted Enterprise feature. Community deployments should not assume an S3 bucket can replace the local persistent storage model used here.
Verify: the PostgreSQL dump and n8n data archive should be non-empty, n8n should become healthy after the maintenance window, and the encryption key should exist in a protected off-server copy.
Step 11 — Test the PostgreSQL backup with an isolated restore
A backup is more useful when you have evidence that it can be restored. Create a disposable PostgreSQL database inside the same PostgreSQL container:
RESTORE_DB=n8n_restore_test docker compose exec -T postgres \ dropdb -U "$POSTGRES_USER" --if-exists "$RESTORE_DB" docker compose exec -T postgres \ createdb -U "$POSTGRES_USER" "$RESTORE_DB"
Restore the dump into the isolated database:
cat "$DUMP" | docker compose exec -T postgres \ psql -U "$POSTGRES_USER" -d "$RESTORE_DB"
Confirm that application tables were restored without depending on a specific n8n internal table name:
docker compose exec -T postgres \ psql -U "$POSTGRES_USER" -d "$RESTORE_DB" \ -c "SELECT count(*) AS public_tables FROM information_schema.tables WHERE table_schema='public';"
The count should be greater than zero.
Remove the disposable database:
docker compose exec -T postgres \ dropdb -U "$POSTGRES_USER" "$RESTORE_DB"
This test verifies the SQL dump can be imported. It does not prove that every credential is usable after a disaster; that also depends on restoring the same N8N_ENCRYPTION_KEY and the required n8n persistent data and configuration.
Verify: the dump should import without a fatal SQL error, the restored database should contain public tables, and the disposable restore database should be removed afterward.
Step 12 — Update n8n with a controlled rollback path
Before an n8n update:
- create fresh backups;
- review n8n release notes and breaking changes;
- choose an exact stable target version;
- change the pinned
N8N_VERSIONinstead of switching blindly to a pre-release.
Edit .env:
nano /opt/n8n/.env
Pull the chosen image and recreate n8n:
cd /opt/n8n docker compose pull n8n docker compose up -d n8n
Check the version, logs, local health, and public endpoint:
docker compose exec n8n n8n --version docker compose logs --tail=200 n8n curl -fsS http://127.0.0.1:5678/healthz curl -I https://n8n.example.com
Do not assume that image rollback alone can reverse every failed upgrade. Database migrations can make downgrades unsafe. Read the version-specific migration and release notes before depending on a previous image as the only rollback plan.
Verify: the intended stable version should be running, the local health endpoint should pass, and the public HTTPS endpoint should remain available after the update.
Step 13 — Verify the self-hosted n8n deployment end to end
Run the final server checks:
cd /opt/n8n docker compose ps docker compose exec n8n n8n --version curl -fsS http://127.0.0.1:5678/healthz curl -I https://n8n.example.com ss -lntp | grep ':5678' sudo nginx -t sudo ufw status numbered ls -lh /opt/n8n/backups docker compose exec n8n env | \ grep -E '^(N8N_HOST|N8N_PROTOCOL|N8N_EDITOR_BASE_URL|N8N_WEBHOOK_URL|N8N_PROXY_HOPS)='
In the n8n UI, confirm all of the following:
- The editor loads over HTTPS.
- The owner account can sign in.
- A manual workflow executes successfully.
- The execution appears in the Executions view.
- A Webhook node generates
https://n8n.example.com/...URLs. - The security audit has been reviewed.
- Port
5678remains loopback-only. - A current PostgreSQL dump exists and has passed the isolated restore test.
- The encryption key and recovery material are protected off-server.
End-to-end verification is complete when the containers are healthy, the pinned stable n8n version is running, Nginx serves the editor over HTTPS, public webhook URLs are correct, the application port is private, and the backup has passed an isolated database restore.
