n8n Webhook Not Working Behind a Reverse Proxy: Fixes

Published on

n8n webhook URLs show localhost:5678 or return 404 behind Nginx or Caddy? Set N8N_WEBHOOK_URL and N8N_PROXY_HOPS, pass the right headers, test with curl.

Network speed graph with two short upload peaks in a VPS control panel

Your n8n editor opens fine at https://n8n.example.com, yet the Webhook node still shows http://localhost:5678/webhook/..., the service you connect it to gets a 404, or the editor keeps showing "Connection lost". The proxy works, but n8n doesn't know its public address, or a header it needs gets lost. Below are the fixes in order, for n8n in Docker behind Nginx or Caddy on Ubuntu 24.04 or Debian 12, with curl tests that show which part fails.

Quick answer: n8n builds webhook URLs from its own settings, not from the request, and the defaults (http, localhost, port 5678) produce http://localhost:5678/. Set N8N_WEBHOOK_URL=https://n8n.example.com/ and N8N_PROXY_HOPS=1, then recreate the container with docker compose up -d (restart keeps the old environment). Versions before 2.30.0 only read the older WEBHOOK_URL, which newer ones accept with a deprecation warning. Have Nginx send Host, the three X-Forwarded headers and the WebSocket upgrade headers; Caddy does that by default. Then test with curl: /webhook-test/ URLs work for 120 seconds after Listen for test event, /webhook/ URLs only once the workflow is published.

Why n8n Shows localhost:5678

n8n's reverse proxy page explains it: n8n creates the webhook URL from N8N_PROTOCOL, N8N_HOST and N8N_PORT, and behind a proxy "that won't work", because n8n runs on port 5678 while the proxy serves port 443. Its URL service takes the first of these that is set:

  1. N8N_WEBHOOK_URL
  2. WEBHOOK_URL, the deprecated old name
  3. protocol://host:port/, leaving out the port only for http on 80 or https on 443

So the defaults give http://localhost:5678/, and N8N_HOST=n8n.example.com with N8N_PROTOCOL=https gives https://n8n.example.com:5678/, the other wrong URL you may see. Setting N8N_PORT=443 is no fix: it is also the port n8n listens on. n8n shows this base URL in the editor and registers it with outside services.

VariableDefaultWhat it does
N8N_WEBHOOK_URLnot setPublic base URL for test and production webhooks (endpoints)
WEBHOOK_URLnot setOld name, deprecated; still works as an alias and logs a warning
N8N_PROXY_HOPS0Number of reverse proxies in front of n8n (deployment)
N8N_EDITOR_BASE_URLnot setPublic editor URL, also used in emails and SAML redirects
N8N_HOST, N8N_PROTOCOL, N8N_PORTlocalhost, http, 5678Build the URL when no webhook URL is set

Step 1: Set the Public URL and the Proxy Hops

Add both variables to the n8n service in your compose file, and publish the port on 127.0.0.1 so every request goes through the proxy. n8n's Postgres example publishes 5678:5678, which binds all network interfaces, and Docker routes published ports past UFW; our Docker and UFW guide explains the fix.

services:
  n8n:
    # keep your image, volumes and other variables
    ports:
      - "127.0.0.1:5678:5678"
    environment:
      - N8N_WEBHOOK_URL=https://n8n.example.com/
      - N8N_PROXY_HOPS=1

N8N_PROXY_HOPS is the number of reverse proxies n8n runs behind, so 1 for one Nginx or Caddy. n8n passes it to Express as trust proxy, which then takes the visitor's address from X-Forwarded-For. Without it, every request seems to come from the proxy: the Webhook node's IP(s) Allowlist answers 403 IP is not allowed to access the webhook!, and the log can show ValidationError: The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is false (default). from n8n's rate limiter. Set it only when n8n can't be reached around the proxy, as the 127.0.0.1 port ensures, or a client could send its own X-Forwarded-For.

Apply it and check what n8n uses now:

cd ~/n8n                     # the folder with your compose file
sudo docker compose up -d
sudo docker compose logs n8n | grep -A1 "Editor is now accessible via"
sudo docker compose logs n8n | grep "WEBHOOK_URL ->"

Docker's restart reference says changed environment variables "are not updated after restarting", while docker compose up recreates containers whose configuration changed and keeps their volumes. The first grep should print your public URL on the next line. The second finds n8n's deprecation warning, WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for both test and production webhooks., shown while the old name is set. The old name also behaves differently: it only sets production URLs, and test URLs then follow N8N_EDITOR_BASE_URL when that is set.

On a Space-Node VPS for n8n and AI agents you get KVM with full root access, so Docker, the proxy and ports 80 and 443 are all yours to set up.

Step 2: Configure Nginx

Install Nginx and certbot as in our Nginx reverse proxy guide, point the DNS record at the VPS, and save this as /etc/nginx/sites-available/n8n:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    listen [::]:80;
    server_name n8n.example.com;
    client_max_body_size 16m;

    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-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_buffering off;
    }
}
sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d n8n.example.com

Certbot's nginx plugin obtains the certificate and adds it to your Nginx configuration. What the lines do, per the proxy module docs:

  • Upgrade, Connection and proxy_http_version 1.1 carry the editor's WebSocket. Both are hop-by-hop headers that nginx doesn't pass on by itself (WebSocket proxying), and before version 1.29.7 nginx proxies with HTTP/1.0 by default. Ubuntu 24.04 ships nginx 1.24 and Debian 12 ships 1.22.
  • Host $host replaces nginx's default $proxy_host, which here is 127.0.0.1:5678. n8n compares the host with the browser's Origin for the editor connection (more below).
  • The three X-Forwarded headers are what n8n's docs ask for.
  • client_max_body_size 16m: nginx's default is 1m, and bigger requests get "413 (Request Entity Too Large)". n8n accepts 16 MiB by default (N8N_PAYLOAD_SIZE_MAX) and files in form-data webhook payloads up to 200 MiB (N8N_FORMDATA_FILE_SIZE_MAX), so raise it if you receive files.
  • proxy_read_timeout 300s: the default is 60 seconds, after which nginx answers 504 to a webhook that only responds when its last node finishes. n8n also pings the editor's WebSocket only every 60 seconds, so a longer timeout keeps idle editor tabs connected.
  • proxy_buffering off passes responses on as they arrive, which matters for the Webhook node's Streaming response mode.

Keep all proxy_set_header lines in one block: nginx only inherits them from an outer level when the current level has none. The stock nginx.conf on Ubuntu 24.04 and Debian 12 has no $connection_upgrade map; if another site file of yours defines one, drop this copy.

Step 3: Or Use Caddy

Caddy does all of this by itself. Install it from Caddy's apt repository, put this in /etc/caddy/Caddyfile and reload:

n8n.example.com {
    reverse_proxy 127.0.0.1:5678
}
sudo systemctl reload caddy
sudo journalctl -u caddy --no-pager | less +G

With the DNS record pointing at the VPS and ports 80 and 443 open, Caddy gets and renews the certificate and redirects HTTP to HTTPS (automatic HTTPS). Its reverse_proxy passes Host through unchanged, sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host (ignoring values a client sends), proxies WebSockets, has no read timeout by default and flushes responses of unknown length, such as streams, immediately. Keep N8N_PROXY_HOPS=1. Caddy needs ports 80 and 443 itself, so run Caddy or Nginx, not both. A reload closes open WebSocket connections, so the editor shows "Connection lost" for a moment and reconnects.

Step 4: Call the Right Webhook URL

Every Webhook node has two URLs (workflow development):

URLWorks whenFor how longData in the editor
/webhook-test/<path>You select Listen for test event120 seconds, one callYes
/webhook/<path>The workflow is publishedUntil you unpublish itNo, see Executions

When nothing matches, n8n answers 404 with one of these messages from its source and logs it after Received request for unknown webhook:.

  • The requested webhook "GET my-path" is not registered. For a test URL, the hint says to click Execute workflow first, after which the URL "only works for one call"; for a production URL, that the workflow must be active, which current n8n calls published.
  • This webhook is not registered for POST requests. Did you mean to make a GET request? The path exists with another HTTP method. The node defaults to GET, so a POST from a form or service lands here.

Step 5: Test With curl

First on the VPS, past the proxy:

curl -i http://127.0.0.1:5678/healthz

A 200 with {"status":"ok"} means n8n runs and the port is published. If curl can't connect, n8n is down or the port isn't published on 127.0.0.1, and Nginx answers 502 Bad Gateway; our 502 guide covers that side. Then, from your own computer and with the workflow published:

curl -i https://n8n.example.com/healthz
curl -i https://n8n.example.com/webhook/my-path
curl -i --json '{"test": true}' https://n8n.example.com/webhook/my-path

Use the plain request for a GET webhook and the --json one for POST. --json sends a POST with Content-Type: application/json and needs curl 7.82.0 or newer (man page); Ubuntu 24.04 has 8.5.0 and Debian 12 has 7.88.1.

You getIt means
200 and {"message":"Workflow was started"}Proxy and webhook work (the default Respond: Immediately reply)
404 with one of the messages aboven8n answered; check the path, the method and publishing
403 IP is not allowed to access the webhook!The allowlist sees the proxy's address: set N8N_PROXY_HOPS
413 Request Entity Too LargeRaise client_max_body_size
502 Bad GatewayNginx can't reach 127.0.0.1:5678
504 Gateway Time-outThe workflow outlasted proxy_read_timeout

For a 504, nginx's error log says "upstream timed out" (source). To check a test URL, select Listen for test event, then send the same request to /webhook-test/my-path within 120 seconds.

When the Editor Says "Connection lost"

The editor gets live updates over a WebSocket at /rest/push (N8N_PUSH_BACKEND defaults to websocket). The Docker image sets NODE_ENV=production, and then n8n's origin check compares the browser's Origin with the host from the Forwarded header, else X-Forwarded-Host, else Host. On a mismatch it logs a warning and closes the connection with Invalid origin!:

sudo docker compose logs n8n | grep "Origin header does NOT match"

It shows both hosts; an expected host of 127.0.0.1:5678 means the proxy sends neither Host $host nor X-Forwarded-Host. No warning, but still "Connection lost"? Then the upgrade headers don't arrive: in the browser's developer tools, the request to /rest/push should end with status 101. If something between the browser and n8n can't carry WebSockets, N8N_PUSH_BACKEND=sse switches to server-sent events, which n8n sends with X-Accel-Buffering: no so nginx doesn't hold them back.

If It Still Fails

  • Long workflows end in 504, or in 524 behind Cloudflare. Cloudflare waits 125 seconds by default (Error 524), and only Enterprise zones can raise that. Let the Webhook node respond immediately, answer early with a Respond to Webhook node, or split the job into a start webhook and a status webhook, as n8n suggests.
  • Cloudflare in front of Nginx. That is two proxies, so set N8N_PROXY_HOPS=2: Cloudflare puts the visitor's address in X-Forwarded-For (headers) and Nginx appends Cloudflare's. Accept web traffic only from Cloudflare's ranges, as in our Cloudflare 521 guide, because Express warns that a shorter path lets clients pick their own address. Cloudflare proxies WebSockets on all plans; the switch is under Network.
  • n8n under a path such as /n8n/. N8N_PATH exists, but n8n's docs advise a subdomain such as n8n.example.com when a reverse proxy is involved.
  • Only /webhook/ proxied. Proxy the whole host: the editor uses /rest/, forms use /form/ and /form-test/, and waiting webhooks use /webhook-waiting/ (endpoint settings).
  • Queue mode with webhook processors. Route /webhook/* to the processors and /webhook-test/* to the main instance (queue mode). Our n8n automation guide has a queue mode compose file.
  • Moved to a new VPS. Bring these variables along; our n8n backup and restore guide covers the rest.

FAQ

Should I use N8N_WEBHOOK_URL or WEBHOOK_URL? N8N_WEBHOOK_URL, on any n8n from 2.30.0. n8n's docs list WEBHOOK_URL as deprecated from 2.35.0; it still works as an alias but logs a warning at startup. Versions before 2.30.0, including the whole 1.x line, only read WEBHOOK_URL. Check yours with sudo docker compose exec n8n n8n --version.

Do I also need N8N_EDITOR_BASE_URL? Only when the editor lives at a different address from your webhooks. While it's empty, n8n uses the webhook URL for the editor address and the OAuth callback URLs (frontend settings), so one variable covers both.

Why does the OAuth Redirect URL in my credentials show localhost:5678? Same cause: n8n builds it from that instance URL plus /rest/oauth2-credential/callback. Set N8N_WEBHOOK_URL (or N8N_EDITOR_BASE_URL), recreate the container, and enter the new redirect URL in the provider's app settings.

Why does n8n say it is "configured to use a secure cookie"? You opened it over plain HTTP, for example at http://203.0.113.10:5678. N8N_SECURE_COOKIE defaults to true, so cookies only go over HTTPS (security variables). Use the HTTPS address through your proxy; n8n's own message calls switching the setting off not recommended.

Sources

All opened on 30 October 2026: