← All posts
Sm0k · CTI Analyst & Engineer
[n8n]··4 min read

Make n8n Webhooks Safer with nginx and ngrok

Expose selected homelab webhooks through ngrok and an nginx allowlist without publishing the n8n editor.

#n8n#webhooks#security#nginx#ngrok#Docker

I wanted Telegram and Discord to trigger workflows in my homelab without putting the entire n8n editor behind a public tunnel. The solution is a small nginx proxy that exposes only the webhook paths I choose.


The Problem

Running n8n at home is straightforward until an external service needs to call it. Telegram, Discord, and similar platforms require a public HTTPS endpoint, while the n8n editor should remain private.

Pointing ngrok directly at port 5678 makes every route on that n8n instance reachable through the tunnel. Authentication may still protect the editor, but the public attack surface is much larger than necessary.

The goal is narrower: publish selected production webhook routes and nothing else.


The Architecture

ngrok forwards traffic to nginx. nginx uses exact path matching and passes only approved webhook requests to n8n; every other route returns 404.

  Internet


 ngrok tunnel


 nginx :8080             exact-path allowlist

     │  POST /webhook/<random-path>

 n8n :5678               private Docker network

This is defense in depth, not authentication. A random path helps reduce opportunistic scanning, but requests still need to be authenticated or verified inside the workflow.


Docker Compose

The n8n editor is bound to loopback, so it remains available locally at http://127.0.0.1:5678 but is not published on every host interface. nginx is also bound to loopback because the ngrok process runs on the same host.

services:
  n8n:
    image: n8nio/n8n
    container_name: n8n
    restart: unless-stopped
    ports:
      - "127.0.0.1:5678:5678"
    volumes:
      - n8n_data:/home/node/.n8n
 
  nginx:
    image: nginx:alpine
    container_name: nginx-webhook-proxy
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - n8n
 
volumes:
  n8n_data:

If you administer n8n from another machine, do not change the binding to 0.0.0.0 without a plan. Prefer a trusted LAN interface, VPN, or a separate authenticated reverse proxy for the editor.

nginx config (nginx.conf)

server {
    listen 8080;
    server_tokens off;
    client_max_body_size 1m;
 
    # Reveal as little as possible for every route not explicitly allowed.
    location / {
        return 404;
    }
 
    # Exact match: similar paths and subpaths do not pass.
    location = /webhook/550e8400-e29b-41d4-a716-446655440000 {
        limit_except POST { deny all; }
 
        proxy_pass http://n8n:5678;
        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_read_timeout 60s;
    }
}

Inside the Compose network, nginx resolves n8n through Docker DNS. It does not use localhost, which would refer to the nginx container itself.

Validate the configuration before restarting:

docker compose exec nginx nginx -t
docker compose restart nginx

Point ngrok at nginx

ngrok http 8080

The public tunnel now terminates at nginx rather than n8n. A quick check should return 404 for the root path while the exact webhook path accepts POST requests:

curl -i https://<your-ngrok-id>.ngrok-free.app/
curl -i -X POST https://<your-ngrok-id>.ngrok-free.app/webhook/<your-path>

Use an Unpredictable Path

Generate a separate random path for every webhook:

uuidgen
# or
python3 -c "import uuid; print(uuid.uuid4())"

An unpredictable path is useful because it removes obvious endpoint names from casual scans. Treat it like a secret URL, but do not treat it as the only security control: URLs can appear in logs, browser history, screenshots, monitoring tools, and provider dashboards.


Verify the Sender

The proxy answers the question “which route may reach n8n?” Authentication answers the more important question: “who sent this request?”

Telegram

When registering a Telegram webhook, set secret_token. Telegram then sends the value in the X-Telegram-Bot-Api-Secret-Token header on every request. Validate that header before processing the payload.

Discord

Discord HTTP interactions must validate X-Signature-Ed25519 and X-Signature-Timestamp against the application's public key. A UUID path or shared header is not a substitute for this signature check. Invalid signatures must be rejected before the workflow performs any action.

For generic callers, n8n's Webhook node can use Basic, Header, or JWT authentication. Choose the strongest mechanism the sender supports and keep secrets in n8n credentials rather than directly in workflow nodes.


Adding Another Webhook

  1. Create a new production webhook in n8n with its own random path.
  2. Configure authentication or provider signature verification.
  3. Add another exact location block to nginx.conf.
  4. Run nginx -t and reload the container.
  5. Verify both the allowed route and a deliberately invalid route.

Do not reuse one path for unrelated integrations. Separate paths make rotation, logging, and incident response much easier.


What This Setup Does—and Does Not—Protect

ScenarioResult
Request to / or the editor routesRejected by nginx
Request to an unknown webhook pathRejected by nginx
GET request to an allowed pathRejected by nginx
Valid path with failed provider verificationMust be rejected by the workflow
Valid path with valid authenticationForwarded and processed
Attacker obtains the path but no valid signature or secretRequest reaches verification, then fails

This design keeps the editor out of the public tunnel and reduces the exposed surface to a small set of routes. It does not replace patching n8n, protecting the host, rotating secrets, monitoring requests, or backing up the instance.

References

← back to blog