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 sudo privileges.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository. docker compose version must print a v2 version.
  • A domain or subdomain (this guide uses n8n.your_domain) with a DNS A record pointing to your_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: postgresdb switches 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_URL and N8N_PROTOCOL make n8n generate public https:// webhook URLs instead of http://localhost:5678.
  • N8N_PROXY_HOPS: 1 tells n8n it sits behind one reverse proxy, so it trusts the X-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:

  1. Click Create Workflow.
  2. Click Add first step, search for Webhook and add it. Set HTTP Method to POST, set Path to hello, and set Respond to Using 'Respond to Webhook' Node.
  3. 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 }}" }
  1. 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_URL is missing or wrong in compose.yaml. Fix it and run docker compose up -d.
  • Editor shows "Connection lost": WebSockets are not reaching n8n. Check the map block and the Upgrade/Connection headers in the Nginx server block.
  • n8n restarts with a "Mismatching encryption keys" error: the key in .env differs from the one saved in the n8n_data volume. 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 by 1000: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.