🐞 You deployed n8n on a VPS, opened the Webhook node, and saw this: http://localhost:5678/webhook/your-path. You try to paste that URL into Shopify or Typeform and nothing works — of course it doesn't, localhost is your server talking to itself. The outside world has no idea where to find it.
I hit this exact wall the first time I deployed n8n on an Oracle Cloud free-tier instance. Spent two hours reading forum threads before I understood what was actually happening. This guide gives you everything I wish existed back then — the root cause, all four environment variable options, the Nginx proxy config that trips everyone up, and a full troubleshooting table for when the obvious fix doesn't work.
The cause: n8n builds webhook URLs from the WEBHOOK_URL environment variable. If it is not set, n8n falls back to http://localhost:5678. Setting it once fixes every webhook node in every workflow.
- Add
WEBHOOK_URL=https://your-domain.com/to your.envfile or Docker environment. - Restart n8n:
docker restart n8norsudo systemctl restart n8n. - Toggle the workflow Active in the editor — webhooks only register on active workflows.
If that alone does not fix it, keep reading — your Nginx config or a conflicting variable is likely the culprit.
Why Does This Happen? The Real Explanation
Most guides jump straight to "set this variable and restart." That works, until it does not — and when it does not, you have no idea why. So let's spend two minutes understanding the actual mechanism.
How n8n Builds Webhook URLs
Every time you open a Webhook node in n8n, the editor generates a URL for you. It constructs that URL at runtime using environment variables in this priority order:
WEBHOOK_URL— if this is set, it is used as the complete base URL and everything else is ignored.N8N_PROTOCOL+N8N_HOST+N8N_PORT— ifWEBHOOK_URLis absent, n8n assembles the URL from these three pieces.- Fallback:
http://localhost:5678— if none of the above are set, this is what you get.
When you run n8n locally for development, the fallback is fine — your browser and your n8n instance are on the same machine. The moment you move to a VPS, your n8n instance is no longer "localhost" to the outside world. But n8n does not know your domain unless you tell it explicitly.
Setting WEBHOOK_URL is not optional on a self-hosted production instance. It is a required configuration step that the official n8n documentation lists but many deployment tutorials skip entirely.
— Confirmed from n8n's official environment variables reference: docs.n8n.io/hosting/configuration/environment-variables
Why Nginx Makes It Worse Without Proper Headers
Many VPS setups run n8n behind an Nginx reverse proxy. This introduces a second layer of confusion: even after you set WEBHOOK_URL correctly, your Nginx config may be stripping or not forwarding the headers that tell n8n it is operating over HTTPS from a public domain.
If Nginx does not pass X-Forwarded-Proto: https, n8n may still generate http:// URLs internally even when your browser sees HTTPS. This is the "I set the variable but it still shows localhost or HTTP" variant of the bug — and it has a different fix.
Mental model: n8n needs to know two things — what domain to put in webhook URLs (WEBHOOK_URL), and what protocol the connection is using (X-Forwarded-Proto from Nginx). Both must be correct for production webhooks to work reliably.
All Environment Variables That Affect Webhook URLs
Before jumping to code, here is a clear map of every variable involved. Understanding which one does what will save you from conflicting configurations — a surprisingly common problem.
WEBHOOK_URL
The complete base URL for all webhooks. Must include trailing slash. Example: https://n8n.yourdomain.com/. When set, overrides N8N_HOST and N8N_PROTOCOL for URL generation.
N8N_HOST
The hostname only (no protocol, no port). Used if WEBHOOK_URL is absent. Example: n8n.yourdomain.com.
N8N_PROTOCOL
Either http or https. Works together with N8N_HOST when WEBHOOK_URL is not set. Default: http.
N8N_PORT
The port n8n listens on internally. Default: 5678. If using a reverse proxy, keep this at default and let Nginx handle 80/443.
N8N_PATH
A sub-path if n8n runs at a path like /n8n/. Rarely needed. Leave empty if n8n is at the root of your domain.
N8N_EDITOR_BASE_URL
Separate URL for the editor UI, if it differs from the webhook base URL. Only needed for advanced split-domain setups.
Do not set both WEBHOOK_URL and N8N_HOST at the same time unless you understand the priority order. WEBHOOK_URL wins. Having both set to different values confuses the configuration and is the source of many "I set it but it still shows localhost" reports on the n8n community forum.
Variable Priority Comparison
| Variable | Priority | What It Controls | Recommended? |
|---|---|---|---|
WEBHOOK_URL |
🥇 Highest | Full webhook base URL | ✅ Yes — always set this |
N8N_HOST |
🥈 Medium | Hostname only | ⚠️ Only if WEBHOOK_URL absent |
N8N_PROTOCOL |
🥈 Medium | http or https | ⚠️ Pair with N8N_HOST |
N8N_PORT |
🥉 Low | Internal listen port | Leave at 5678 if using proxy |
| None set | ❌ Fallback | http://localhost:5678 | ❌ This is the bug |
Fix by Deployment Type (Choose Your Setup)
The fix is the same concept in every case, but the exact file and command differ depending on how you installed n8n. Find your setup below.
Fix A: Docker Compose (Most Common)
Open your docker-compose.yml file and add the environment variables to the n8n service block. The critical line is WEBHOOK_URL. Replace your-domain.com with your actual domain — including the trailing slash.
version: '3.8'
services:
n8n:
image: n8nio/n8n:latest
restart: unless-stopped
environment:
- WEBHOOK_URL=https://your-domain.com/ # ← This is the critical fix
- N8N_HOST=your-domain.com # Fallback, keep it consistent
- N8N_PROTOCOL=https
- N8N_PORT=5678
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=admin
- N8N_BASIC_AUTH_PASSWORD=your-password
ports:
- "127.0.0.1:5678:5678" # Bind to localhost only — Nginx handles public traffic
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
After saving the file, apply the changes:
docker compose down && docker compose up -d
# Or if using older docker-compose command:
docker-compose down && docker-compose up -d
Fix B: .env File (systemd or pm2)
If you installed n8n with npm install -g n8n and run it as a systemd service or with pm2, your environment variables live in a .env file — typically at /home/node/.n8n/.env or wherever your n8n data folder is.
WEBHOOK_URL=https://your-domain.com/
N8N_HOST=your-domain.com
N8N_PROTOCOL=https
N8N_PORT=5678
N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=your-secure-password
Then restart the service:
# systemd: sudo systemctl daemon-reload sudo systemctl restart n8n sudo systemctl status n8n # Verify it started cleanly # pm2: pm2 restart n8n pm2 logs n8n --lines 20 # Check for startup errors
Fix C: Single Docker run Command
If you started n8n with a bare docker run command, stop the existing container and restart it with the -e flags:
docker stop n8n && docker rm n8n docker run -d \ --name n8n \ --restart unless-stopped \ -p 127.0.0.1:5678:5678 \ -e WEBHOOK_URL=https://your-domain.com/ \ -e N8N_HOST=your-domain.com \ -e N8N_PROTOCOL=https \ -e N8N_PORT=5678 \ -v n8n_data:/home/node/.n8n \ n8nio/n8n:latest
Fix D: Railway, Render, or Other Cloud Platforms
On cloud deployment platforms, environment variables are set through the platform dashboard, not a file. The variable name and value are the same — only the location changes.
- Railway: Project → Service → Variables → Add Variable:
WEBHOOK_URL=https://your-railway-domain.up.railway.app/ - Render: Service → Environment → Add Environment Variable with the same key/value.
- Fly.io: Add to
fly.tomlunder[env], then runfly deploy.
Verify the variable was applied: After restarting, open a Webhook node and look at the URL it generates. If it still shows localhost, the variable was not picked up — check for typos in the variable name or confirm the service actually restarted.
The Nginx Reverse Proxy Configuration (Critical Headers)
This is the part most tutorials get wrong or skip entirely. Even with WEBHOOK_URL set correctly, if your Nginx configuration does not pass the right headers, n8n may still generate incorrect URLs or refuse incoming webhook requests.
Complete Working Nginx Config for n8n
server {
listen 80;
server_name your-domain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL — managed by Certbot / Let's Encrypt
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Increase timeout for long-running webhook responses
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
# ── These 4 headers are critical for n8n ──
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# WebSocket support (required for n8n editor live updates)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Disable buffering for SSE / streaming responses
proxy_buffering off;
proxy_cache off;
}
}
Why Each Highlighted Header Matters
proxy_set_header Host $host— tells n8n the actual public hostname the request arrived at. Without this, n8n sees the internal127.0.0.1address and may generate localhost URLs.proxy_set_header X-Forwarded-Proto $scheme— tells n8n whether the original request used HTTP or HTTPS. Without this, n8n generateshttp://webhook URLs even though your site is on HTTPS.proxy_set_header X-Forwarded-For— passes the real visitor IP. Required if you use IP-based rate limiting or logging inside n8n.proxy_set_header X-Real-IP— similar to above, used by some n8n nodes that read the sender's IP from request headers.
After editing the Nginx config, always test before reloading:
sudo nginx -t # Test config for syntax errors sudo systemctl reload nginx # Apply if test passes
Getting a free SSL certificate: If you have not set up HTTPS yet, run sudo apt install certbot python3-certbot-nginx then sudo certbot --nginx -d your-domain.com. Certbot automatically edits your Nginx config and sets up auto-renewal.
Step That Everyone Forgets: Activate the Workflow
You have set the environment variables, restarted n8n, verified the Nginx config — and the webhook still returns 404. Nine times out of ten, this is the reason: the workflow is not Active.
n8n has two URL types for webhooks:
| URL Type | Format | When it works | Use for |
|---|---|---|---|
| Test URL | /webhook-test/your-path |
Only while editor is open + you clicked "Listen for Test Event" | Development only |
| Production URL ✅ | /webhook/your-path |
Always — as long as the workflow is Active | Live external apps |
Toggle the workflow Active using the switch in the top-right corner of the n8n editor. The switch turns green when active. At this point, the Production URL registers itself with n8n's router and will accept incoming POST requests 24/7.
Always configure Shopify, Typeform, or any other external app with the Production URL — the one that contains /webhook/ (not /webhook-test/). The test URL stops working the moment you close the editor tab.
How to Test Your Webhook After Fixing It
Before connecting your real external app, verify the webhook works with a controlled test. Here are three methods, from quickest to most thorough.
Method 1: cURL (Fastest)
Open a terminal on your local machine and send a POST request directly:
curl -X POST https://your-domain.com/webhook/your-path \
-H "Content-Type: application/json" \
-d '{"test": true, "source": "curl", "message": "Hello from TriggerWorkflow"}'
# Expected response: {"message":"Workflow was started"}
If you see {"message":"Workflow was started"}, your webhook is live and working. Switch to n8n's Execution log (left sidebar → Executions) to inspect the incoming data.
Method 2: webhook.site (Visual)
For a visual way to inspect the exact payload structure your webhook receives, use webhook.site. Set your external app to send to webhook.site temporarily, inspect the structure, then swap in your real n8n Production URL.
Method 3: Postman (Most Thorough)
Download Postman (free), create a new POST request, set the URL to your Production webhook URL, add a JSON body, and send. Postman shows you the full response headers and body — useful for debugging authentication issues.
Bonus: Test Webhooks Locally With ngrok (No VPS Needed)
If you are still in development and do not have a VPS yet — or if you want to test a new workflow without deploying to production — ngrok gives you a public HTTPS URL that tunnels directly to your local n8n instance.
Install ngrok and start a tunnel
Download ngrok from ngrok.com (free tier works), authenticate it once, then run: ngrok http 5678. ngrok will print a temporary HTTPS URL like https://abc123.ngrok.io.
Set WEBHOOK_URL to the ngrok address
Add WEBHOOK_URL=https://abc123.ngrok.io/ to your local .env file and restart n8n. Open a Webhook node — it now shows the ngrok URL instead of localhost.
Connect your external app and test
Paste the ngrok Production URL into Shopify, Typeform, or any other service. Submissions will tunnel through ngrok to your local n8n. Note: the free ngrok URL changes every time you restart ngrok, so update WEBHOOK_URL accordingly.
Cloudflare Tunnel is a free ngrok alternative with a stable URL. Run cloudflared tunnel --url http://localhost:5678. Cloudflare assigns a consistent subdomain on your free account — no URL rotation on restart.
Advanced Troubleshooting: When the Standard Fix Does Not Work
If you set WEBHOOK_URL, restarted, and are still seeing problems, use this diagnostic table. These are the ten real-world failure scenarios I have personally debugged across different VPS providers and deployment setups.
| Symptom | Most Likely Cause | Fix |
|---|---|---|
URL still shows localhost after restart |
Variable not applied — service did not restart, or env file not found | Verify with docker exec n8n env | grep WEBHOOK or printenv WEBHOOK_URL |
URL shows correct domain but uses http:// not https:// |
Nginx missing X-Forwarded-Proto $scheme header |
Add the header to your Nginx location block and reload Nginx |
| Webhook URL correct but returns 404 | Workflow is not Active, or path mismatch | Toggle workflow Active; confirm URL path matches Webhook node setting exactly |
| Works from cURL but not from Shopify / Typeform | HTTPS certificate issue or Nginx blocking non-standard User-Agent | Test with curl -I https://your-domain.com/webhook/path; check SSL validity |
| Connection refused on port 443 | Nginx not running or SSL cert missing | Run sudo systemctl status nginx and sudo nginx -t |
| 502 Bad Gateway on webhook call | n8n is not running or crashed after restart | docker ps or systemctl status n8n; check logs for startup errors |
| Works once, then returns 404 on next call | n8n restarted and workflow toggle reset to inactive | Re-activate workflow; check if a crash loop is causing restarts |
| Webhook fires but data is empty in n8n | External app sending form-encoded, not JSON; or sending to wrong field path | Inspect raw webhook data in n8n Executions log; check $json.body vs $json |
| Oracle Cloud — correct env but still localhost | Oracle Security List (not OS firewall) blocking ports 80/443 | Add Ingress Rules for port 80 and 443 in Oracle Cloud Console → Networking → Security Lists |
| Both WEBHOOK_URL and N8N_HOST set, conflict | Two variables pointing to different domains | Remove N8N_HOST and N8N_PROTOCOL — WEBHOOK_URL alone is sufficient |
How to Verify the Variable Was Actually Applied
Never assume the restart worked. Verify the variable is live inside the running container or process:
# Docker: docker exec n8n env | grep -E "WEBHOOK|N8N_HOST|N8N_PROTOCOL" # systemd — check what the process sees: sudo systemctl show n8n --property=Environment # Expected output should include: WEBHOOK_URL=https://your-domain.com/
The Definitive Verification: Open a Webhook Node
After restarting, open any Webhook node in n8n. The URL it generates is the ground truth. If it shows https://your-domain.com/webhook/..., everything is configured correctly. If it still shows localhost, the environment variable is not reaching the n8n process — no matter what the file says.
Oracle Cloud Free Tier: Extra Step Required
Oracle Cloud's free tier is popular for self-hosting n8n because it is genuinely free with no credit card expiry catch. But it has one networking quirk that catches almost everyone: two separate firewalls.
The Two Firewalls Problem
Most VPS providers have one firewall. Oracle Cloud has two:
- OS-level firewall (
iptables/ufw) — the one you configure withsudo ufw allow 443. - Oracle Security List (in the Oracle Cloud Console web UI) — a separate network-level firewall that sits in front of your instance.
If you only open port 443 in the OS firewall but forget the Oracle Security List, HTTPS traffic never even reaches your server. The webhook error persists regardless of any n8n configuration.
Open ports 80 and 443 in Oracle Security List
Log in to Oracle Cloud Console → Networking → Virtual Cloud Networks → your VCN → Security Lists → Default Security List → Add Ingress Rules. Add two rules: Source CIDR 0.0.0.0/0, Destination Port Range 80. Repeat for port 443.
Open ports in OS firewall
Run: sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 443 -j ACCEPT and the same for port 80. Then: sudo netfilter-persistent save to make the rules persistent after reboot.
Verify connectivity from outside
From your laptop or phone, run curl -I https://your-oracle-domain.com. If you see a 200 or 301 response, both firewalls are open and Nginx is responding. Now test the webhook with cURL.
Frequently Asked Questions
WEBHOOK_URL environment variable. If that variable is not set, it falls back to http://localhost:5678. This is intentional default behavior for local development — you must explicitly set WEBHOOK_URL=https://your-domain.com/ after any production deployment.WEBHOOK_URL is the master variable — it sets the complete base URL for all webhook nodes and overrides everything else. N8N_HOST and N8N_PROTOCOL are lower-priority fallbacks used when WEBHOOK_URL is absent. Best practice: always set WEBHOOK_URL and leave the other two either unset or consistent with it..env file has zero effect on a running process. Run docker restart n8n (Docker) or sudo systemctl restart n8n (systemd) after every environment variable change, and verify the change took effect with docker exec n8n env | grep WEBHOOK.ngrok http 5678) or Cloudflare Tunnel (cloudflared tunnel --url http://localhost:5678) to get a public HTTPS URL that tunnels to your local n8n. Set WEBHOOK_URL to that URL, restart n8n, and your local instance will accept webhooks from any external service.WEBHOOK_URL=https://your-domain.com/ in your .env file. Then open ports 80 and 443 in both Oracle's Security List (in the Oracle Cloud Console web UI) AND in the OS-level iptables firewall. Oracle Cloud has two separate firewalls — the standard fix only addresses one of them. See the Oracle Cloud section above for exact commands.WEBHOOK_URL to your public domain. Ensure the reverse proxy passes X-Forwarded-Proto: https and Host: your-domain.com headers to n8n. Without X-Forwarded-Proto, n8n may still generate http:// webhook URLs even though the public-facing URL uses HTTPS. The Nginx config in Section 4 above includes all required headers.Continue Learning on TriggerWorkflow
Webhook Fixed — Now Build Something With It
Your n8n instance is now publicly accessible. Import a ready-made workflow from our pillar guide and have your first real automation running in under 30 minutes.
Browse Free Workflows Soon →