I self-hosted n8n on Ubuntu 24.04 — webhooks still pointed at localhost

I self-hosted n8n on Ubuntu 24.04 — webhooks still pointed at localhost

I wanted visual workflows for webhooks and APIs without sending credentials to Zapier. n8n came up behind Nginx; every webhook URL still said localhost:5678 until N8N_WEBHOOK_URL and N8N_PROXY_HOPS=1 were set. After that, the editor and the runner stayed honest.

 n8n self-hosted workflow automation overview

Caption: n8n on my VPS: triggers, webhooks, API calls, and a run history I can actually read.

Why I wanted this on my server

I had glue: cron, a couple of webhook PHP files, and one Zapier-ish SaaS I did not want holding CRM tokens. n8n is a visual workflow tool that still lets me drop in HTTP nodes and code. Self-hosting meant credentials, execution logs, and payloads stay on the VPS next to the apps they touch. That includes finance exports, support tickets, source-code events, and homelab alerts I would not send to a hosted integration product.

I treat it like CI: if someone owns n8n, they own every API key I saved. HTTPS and a stable encryption key are not optional. A compromised instance can send unwanted API calls, change records, or fire webhooks in a loop. The install is the easy part; day-two operations are the job.

What I actually installed

Ubuntu 24.04 LTS, Docker Compose, docker.n8n.io/n8nio/n8n:stable, PostgreSQL 16, an external task runner (n8nio/runners), Nginx + Let’s Encrypt. Project under /opt/n8n. Editor at https://n8n.example.com, app bound to 127.0.0.1:5678.

Hardware: 2 vCPU / 2–4 GB for light use; 4 vCPU / 8 GB if executions are frequent or transformations are large. SSD for Postgres, credentials metadata, execution logs, binary files, and backups. Off-server copies. Extra RAM if workflows run heavy JavaScript or pull large files.

Security I would not skip: HTTPS before users and production webhooks, N8N_ENCRYPTION_KEY stored off-box as well as in .env, file access limited to /files unless I have a documented reason, outbound firewall if workflows should only call approved hosts, and a dump of Postgres plus the n8n volume before upgrades.

I keep Nginx, Certbot, rsync, jq, and the PostgreSQL client on the host. Queue mode with Redis is the next step when long workflows should leave the editor process — same encryption key and runner token across main and workers.

Where it broke

On a fresh Ubuntu 24.04 box this install is famous for webhook URLs that still say localhost.

The editor loaded on HTTPS. I added a webhook trigger. n8n printed http://localhost:5678/webhook/.... External services cannot hit that. OAuth redirects break the same way.

Official reverse-proxy notes want:

  • N8N_WEBHOOK_URL=https://n8n.example.com/
  • N8N_EDITOR_BASE_URL=https://n8n.example.com/
  • N8N_PROTOCOL=https
  • N8N_HOST = the public hostname
  • N8N_PROXY_HOPS=1 because Nginx is one hop in front
  • Nginx forwarding X-Forwarded-* and WebSocket upgrade headers

After I set those and recreated the n8n container, test webhook URLs showed the public hostname.

The second footgun: N8N_ENCRYPTION_KEY. If you restore Postgres without the same key, saved credentials will not decrypt. I keep that key in the password manager, not only in .env.

Runner jobs that never start are usually a mismatched RUNNERS_AUTH_TOKEN or the broker URI not http://n8n:5679 on the Compose network. Check docker compose logs n8n-runner.

Other documented issues I kept: Postgres unhealthy — init-data.sh must be executable and mounted, and .env names must match. Binary uploads failing — Nginx client_max_body_size, writable n8n volume, workflows not reading outside /files. OAuth redirects to an internal hostname — the external app must use this instance’s public callback, not localhost. Disk filling — execution retention and binary data, not just the n8n image size. The init-data.sh script only runs when the Postgres volume is new; changing users later means a manual migration or a recreated volume after a verified backup.

 n8n Docker Compose stack

Caption: Postgres, n8n, external runner on a private network; Nginx on 443.

The working install

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg openssl ufw nginx certbot python3-certbot-nginx jq rsync postgresql-client

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

1. Install Docker Engine

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

2. Create the project directory

sudo mkdir -p /opt/n8n/local-files
sudo chown -R "$USER":"$USER" /opt/n8n
chmod 700 /opt/n8n
cd /opt/n8n

local-files mounts as /files. That is the sandbox for file nodes.

3. Generate environment values

cd /opt/n8n

POSTGRES_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')"
POSTGRES_NON_ROOT_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')"
N8N_ENCRYPTION_KEY="$(openssl rand -hex 32)"
RUNNERS_AUTH_TOKEN="$(openssl rand -hex 32)"

cat > .env <<EOF
N8N_VERSION=stable
N8N_DOMAIN=n8n.example.com
GENERIC_TIMEZONE=UTC

POSTGRES_USER=n8n_admin
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB=n8n
POSTGRES_NON_ROOT_USER=n8n_app
POSTGRES_NON_ROOT_PASSWORD=${POSTGRES_NON_ROOT_PASSWORD}

N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
EOF

chmod 600 .env

Keep N8N_ENCRYPTION_KEY in the password manager as well as .env.

4. Create the PostgreSQL init script

Official example creates a less-privileged DB user on first boot. Save as init-data.sh:

cat > init-data.sh <<'EOF'
#!/bin/bash
set -e

if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then
  psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
    CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';
    GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};
    GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};
EOSQL
else
  echo "SETUP INFO: No non-root PostgreSQL user variables were provided."
fi
EOF

chmod 700 init-data.sh

This only runs when the Postgres volume is new.

5. Create the Compose file

Save as compose.yaml in /opt/n8n:

volumes:
  db_storage:
  n8n_storage:

services:
  postgres:
    image: postgres:16
    restart: always
    environment:
      - POSTGRES_USER
      - POSTGRES_PASSWORD
      - POSTGRES_DB
      - POSTGRES_NON_ROOT_USER
      - POSTGRES_NON_ROOT_PASSWORD
    volumes:
      - db_storage:/var/lib/postgresql/data
      - ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh:ro
    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:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
      - DB_POSTGRESDB_USER=${POSTGRES_NON_ROOT_USER}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_NON_ROOT_PASSWORD}
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
      - N8N_RUNNERS_MODE=external
      - N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
      - 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
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - TZ=${GENERIC_TIMEZONE}
      - N8N_RESTRICT_FILE_ACCESS_TO=/files
      - NODE_ENV=production
    ports:
      - "127.0.0.1:5678:5678"
    volumes:
      - n8n_storage:/home/node/.n8n
      - ./local-files:/files
    depends_on:
      postgres:
        condition: service_healthy

  n8n-runner:
    image: n8nio/runners:${N8N_VERSION}
    restart: always
    environment:
      - N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
    depends_on:
      - n8n
cd /opt/n8n
docker compose config --services
docker compose pull
docker compose up -d

docker compose ps
docker compose logs --tail=80 n8n

Expect postgres, n8n, and n8n-runner. If Postgres is unhealthy, read its logs before restarting in a loop.

6. Configure Nginx and HTTPS

sudo tee /etc/nginx/sites-available/n8n >/dev/null <<'EOF'
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 Upgrade $http_upgrade;
        proxy_set_header 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-Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}
EOF

sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/n8n
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d n8n.example.com --email admin@example.com --agree-tos --redirect

Create the owner account after HTTPS works. Do not add production credentials yet.

Configuration

Public URL variables are the whole game. N8N_RESTRICT_FILE_ACCESS_TO=/files keeps file nodes in the mounted folder. Configure SMTP from n8n’s env reference after the base deploy works — bad SMTP blocks invites.

What you need to look at

External runners are now the documented default in the hosting examples, not an advanced extra. The main process still looks “up” while jobs sit idle if the runner token or broker URI is wrong. I wasted time restarting n8n when n8n-runner was the broken service.

Queue mode with Redis is the next step when long workflows should leave the editor process. Same N8N_ENCRYPTION_KEY and runner token across main and workers.

Usage

 n8n workflow lifecycle

Caption: Trigger, credential boundary, transform, destination, then a run I can inspect.

  1. Manual Trigger → Set/Edit Fields → HTTP Request to a safe test URL.
  2. Run it, inspect execution data, export a copy.
  3. Confirm webhook test URLs show https://n8n.example.com, not localhost.
  4. One low-privilege credential per integration.

 n8n security and backup model

Caption: HTTPS, restricted /files, encryption key, Postgres dumps, off-server copies.

Backup, expose, next step

cd /opt/n8n
mkdir -p backups

BACKUP_DATE="$(date +%F-%H%M%S)"
docker compose exec -T postgres pg_dump -U n8n_admin -d n8n > "backups/n8n-${BACKUP_DATE}.sql"
docker run --rm -v n8n_n8n_storage:/data -v "$(pwd)/backups:/backup" alpine   tar -czf "/backup/n8n-storage-${BACKUP_DATE}.tar.gz" -C /data .

rsync -av --chmod=600 backups/ backupuser@backup.example.com:/srv/backups/n8n/

A restore needs the dump, the n8n volume, .env, and the encryption key together.

cd /opt/n8n
docker compose pull
docker compose down
docker compose up -d
docker compose logs --tail=120 n8n

What I have running now: n8n on HTTPS with Postgres, an external runner, file access limited to /files, webhook URLs that match the public hostname. I create the owner account only after Certbot succeeds, and I still do not save production credentials until a manual workflow runs through the runner. Next: one real workflow with least-privilege credentials, export that workflow after changes, then a restore test that includes the encryption key. I am not enabling queue mode until a workflow actually needs it.

Did you hit the same wall?

I got stuck on webhook URLs still showing localhost:5678 until N8N_WEBHOOK_URL and N8N_PROXY_HOPS=1 were set. Did you hit the same thing, or a different one — encryption key on restore, runner token, Postgres init script? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.