Secure n8n with Nginx Reverse Proxy, TLS, and Security Headers
Put your self-hosted n8n behind Nginx with Let's Encrypt TLS, security headers, rate limiting, firewall rules, and webhook protection. Every step includes a verification command.
This tutorial puts your self-hosted n8n instance behind Nginx with TLS, security headers, rate limiting, and firewall rules. You will go from an exposed n8n on port 5678 to a production-ready setup where only HTTPS traffic reaches the editor and webhooks.
Prerequisites:
- n8n running via Docker Compose on a VPS (Install n8n with Docker Compose on a VPS)
- A domain name (e.g.
n8n.example.com) with an A record pointing to your server IP - SSH access to your server as a non-root user with
sudo - Ports 80 and 443 open at your hosting provider's firewall (Virtua Cloud opens these by default)
This guide assumes Ubuntu 24.04 LTS. Commands work on Debian 12 with minor adjustments. n8n version 2.x is used throughout.
How do I configure Nginx as a reverse proxy for n8n with WebSocket support?
Nginx sits between the internet and n8n, forwarding requests to localhost:5678. The n8n editor relies on WebSocket connections for real-time updates. Without proper WebSocket proxying, the editor UI freezes and shows "Connection lost." You need proxy_pass to localhost:5678, HTTP/1.1 upgrade headers for WebSocket, and the N8N_PROXY_HOPS=1 environment variable so n8n reads the real client IP from X-Forwarded-For.
Install Nginx
sudo apt update && sudo apt install -y nginx
Verify Nginx is running:
sudo systemctl status nginx
The status shows active (running) in green. If not, start and enable it:
sudo systemctl enable --now nginx
The enable --now flag does two things: enable makes Nginx start automatically after a reboot, and --now starts it immediately.
Create the Nginx server block
Create a new configuration file for your n8n domain:
sudo nano /etc/nginx/sites-available/n8n.example.com
Paste this configuration. Replace n8n.example.com with your actual domain:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name n8n.example.com;
# Will be replaced by Certbot later
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_cache off;
chunked_transfer_encoding off;
}
}
The map block at the top handles WebSocket upgrades. When a browser sends an Upgrade: websocket header, Nginx passes it through. For regular HTTP requests, it sends close. This keeps the n8n editor responsive.
proxy_buffering off and proxy_cache off prevent Nginx from buffering n8n's server-sent events, which would cause the editor to lag.
Enable the site and test the config:
sudo ln -s /etc/nginx/sites-available/n8n.example.com /etc/nginx/sites-enabled/
sudo nginx -t
Output:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
If the test passes, reload:
sudo systemctl reload nginx
Verify n8n is reachable through Nginx:
curl -s -o /dev/null -w "%{http_code}" http://n8n.example.com
A 200 response means Nginx is proxying traffic to n8n. A 502 means n8n is not running on port 5678. Check it with docker ps.
Update n8n environment variables
Open your n8n .env file (in the same directory as your docker-compose.yml):
nano ~/n8n-docker/.env
Add or update these variables:
N8N_HOST=n8n.example.com
N8N_PROTOCOL=https
N8N_PROXY_HOPS=1
WEBHOOK_URL=https://n8n.example.com/
N8N_EDITOR_BASE_URL=https://n8n.example.com/
N8N_SECURE_COOKIE=true
What each variable does:
| Variable | Value | Purpose |
|---|---|---|
N8N_HOST |
Your domain | Tells n8n which hostname to use in webhook URLs |
N8N_PROTOCOL |
https |
n8n generates HTTPS webhook URLs instead of HTTP |
N8N_PROXY_HOPS |
1 |
n8n trusts one layer of X-Forwarded-For to get the real client IP |
WEBHOOK_URL |
Full URL | Overrides the auto-generated webhook base URL |
N8N_EDITOR_BASE_URL |
Full URL | URL used in email notifications and SAML redirects |
N8N_SECURE_COOKIE |
true |
Cookies are only sent over HTTPS. Prevents session hijacking on plain HTTP |
Restart n8n to apply the changes:
cd ~/n8n-docker && docker compose down && docker compose up -d
Verify n8n picked up the new environment:
docker compose logs --tail=20 n8n | grep -i "editor\|webhook\|proxy"
The log lines reference your domain and HTTPS protocol.
How do I add Let's Encrypt TLS to my n8n reverse proxy?
Certbot automates Let's Encrypt certificate issuance and auto-configures Nginx for TLS. After running Certbot, all HTTP traffic redirects to HTTPS, and your n8n editor and webhooks are encrypted in transit.
Install Certbot
sudo apt install -y certbot python3-certbot-nginx
Obtain the certificate
sudo certbot --nginx -d n8n.example.com
Certbot will:
- Verify you own the domain via an HTTP-01 challenge
- Obtain a certificate from Let's Encrypt
- Modify your Nginx config to add TLS settings and an HTTP-to-HTTPS redirect
When prompted for an email address, enter a real one. You will get renewal failure warnings there.
Verify TLS works
curl -I https://n8n.example.com
Check for HTTP/2 200 in the response. If you see a certificate error, wait a few minutes for DNS propagation.
Test the certificate chain from your local machine (not the server):
openssl s_client -connect n8n.example.com:443 -servername n8n.example.com </dev/null 2>/dev/null | head -20
Look for Verify return code: 0 (ok).
Verify auto-renewal
Certbot installs a systemd timer that renews certificates before they expire. Confirm it is active:
sudo systemctl status certbot.timer
The status shows active (waiting). Test the renewal process without actually renewing:
sudo certbot renew --dry-run
A successful dry run means your certificates will renew automatically every 60-90 days.
What security headers does n8n need in production?
Security headers tell browsers how to handle your site's content. Without them, your n8n editor is vulnerable to clickjacking, MIME-type sniffing attacks, and cross-site scripting. Add six headers to your Nginx configuration to close these gaps.
Open the Nginx config that Certbot modified:
sudo nano /etc/nginx/sites-available/n8n.example.com
Inside the server block that listens on port 443 (the one Certbot created), add these headers above the location / block:
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss://n8n.example.com; frame-ancestors 'none'" always;
Replace n8n.example.com in the CSP connect-src directive with your actual domain. The wss:// entry allows WebSocket connections to the editor.
What each header does:
| Header | Value | What it prevents |
|---|---|---|
Strict-Transport-Security |
1 year, subdomains | Browser always uses HTTPS. Stops SSL stripping attacks |
X-Frame-Options |
DENY | No one can embed your n8n editor in an iframe. Stops clickjacking |
X-Content-Type-Options |
nosniff | Browser trusts the declared MIME type. Stops MIME confusion attacks |
Referrer-Policy |
strict-origin-when-cross-origin | Limits referrer info leaked to external sites |
Permissions-Policy |
Deny camera, mic, geo | Browser blocks access to hardware APIs n8n does not need |
Content-Security-Policy |
See above | Controls which scripts, styles, and connections the browser allows |
The CSP header is the most complex. n8n's editor uses inline scripts and eval() for the workflow builder, so 'unsafe-inline' and 'unsafe-eval' are required for script-src. This is a known trade-off. The other directives are restrictive: only same-origin resources and WebSocket connections to your domain.
Test and reload:
sudo nginx -t && sudo systemctl reload nginx
Verify the headers are present:
curl -sI https://n8n.example.com | grep -iE "strict-transport|x-frame|x-content|referrer|permissions|content-security"
All six headers appear in the output. If any are missing, check for typos in the config file.
How do I configure the firewall for n8n on a VPS?
UFW blocks all traffic except what you allow. For n8n behind Nginx, only three ports need to be open: 22 (SSH), 80 (HTTP, for Certbot renewal and redirects), and 443 (HTTPS). Port 5678, where n8n listens directly, must be blocked from the outside. Many guides skip this step, leaving n8n accessible without TLS.
For a deeper look at UFW, see How to Set Up a Linux VPS Firewall with UFW and nftables.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp comment 'SSH'
sudo ufw allow 80/tcp comment 'HTTP - Certbot renewal'
sudo ufw allow 443/tcp comment 'HTTPS - n8n via Nginx'
sudo ufw enable
When prompted, type y to confirm. Verify the rules:
sudo ufw status verbose
Three ALLOW rules appear for ports 22, 80, and 443. Port 5678 is not listed, meaning it is blocked.
Confirm n8n is not directly reachable. From your local machine:
curl -s --connect-timeout 3 http://n8n.example.com:5678 || echo "Connection refused - correct!"
If you get "Connection refused" or a timeout, the firewall is working. If you get an n8n response, Docker is likely bypassing UFW. Docker modifies iptables directly and can override UFW rules.
Bind n8n to localhost and stop Docker from bypassing UFW
The simplest fix is to bind n8n to localhost only. In your docker-compose.yml, change the ports mapping:
ports:
- "127.0.0.1:5678:5678"
This ensures n8n only accepts connections from the same machine. Nginx on the same server can reach it, but outside traffic cannot.
Restart n8n:
cd ~/n8n-docker && docker compose down && docker compose up -d
If that alone does not fix it (test again with curl from your local machine), you can also disable Docker's iptables management. Edit /etc/docker/daemon.json:
sudo nano /etc/docker/daemon.json
Add:
{
"iptables": false
}
Then restart Docker:
sudo systemctl restart docker
cd ~/n8n-docker && docker compose up -d
Warning: Setting "iptables": false stops Docker from creating NAT and forwarding rules. This can break container-to-container communication and outbound internet access from containers. If your n8n workflows make HTTP requests to external APIs (most do), test outbound connectivity after this change. The localhost binding alone is usually enough.
Verify again from your local machine that port 5678 is unreachable.
How do I rate limit n8n webhook endpoints in Nginx?
Rate limiting protects your webhook endpoints from abuse and denial-of-service attempts. You define a request rate per IP address. Legitimate integrations (GitHub, Stripe) send webhooks at a predictable rate. An attacker hammering your webhook URL gets a 429 Too Many Requests response instead of triggering workflows.
For more on rate limiting strategies, see Nginx Rate Limiting and DDoS Protection.
Add a limit_req_zone directive at the top of your Nginx config file, outside any server block, next to the existing map directive:
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=editor:10m rate=30r/s;
This creates two zones:
webhooks: 10 requests per second per IP. Handles external service callbacks.editor: 30 requests per second per IP. The editor makes many small API calls, so it needs a higher limit.
Inside the server block listening on 443, add a separate location block for webhook paths before the main location /:
# Rate limit webhook endpoints
location /webhook/ {
limit_req zone=webhooks burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
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;
# Generous timeout for long-running webhook workflows
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /webhook-test/ {
limit_req zone=webhooks burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
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;
}
The burst=20 parameter allows short spikes of up to 20 requests before rate limiting kicks in. This handles cases like GitHub sending multiple webhook events from a single push. nodelay processes burst requests immediately instead of queuing them.
In the main location / block, add the editor rate limit:
location / {
limit_req zone=editor burst=50 nodelay;
limit_req_status 429;
# ... existing proxy settings ...
}
Test and reload:
sudo nginx -t && sudo systemctl reload nginx
Verify rate limiting works by sending a burst of requests to a test webhook:
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code} " https://n8n.example.com/webhook/test-rate-limit
done
echo
The output shows a mix of 404 responses (the webhook path does not exist) and 429 responses once the rate limit kicks in.
How do I lock down the n8n editor to specific IP addresses?
By default, anyone who knows your n8n URL can access the login page. IP allowlisting at the Nginx level adds a layer before n8n's own authentication. Only requests from your IP address (or your VPN) reach the editor. Webhook endpoints stay open to the internet so external services can call them.
Add a new location block for the editor paths. Place it after the webhook locations and before the main location /:
# Restrict editor/API access to specific IPs
location /rest/ {
allow 203.0.113.50; # Your office/home IP
allow 10.0.0.0/8; # Your VPN range
deny all;
limit_req zone=editor burst=50 nodelay;
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;
}
Replace 203.0.113.50 with your actual IP. Find it with:
curl -4 ifconfig.me
The /rest/ path handles the n8n editor API calls. The main location / still serves the editor frontend and webhooks. For tighter lockdown, you can also restrict the root path and add separate open locations for /webhook/ and /webhook-test/ only.
Test and reload:
sudo nginx -t && sudo systemctl reload nginx
Verify from your allowed IP:
curl -s -o /dev/null -w "%{http_code}" https://n8n.example.com/rest/settings
A 200 means you are allowed. From a different IP (use a mobile phone not on your Wi-Fi), access https://n8n.example.com/rest/settings in a browser. You should get a 403 Forbidden.
How do I secure n8n webhooks against unauthorized access?
Webhook URLs are public by default. Anyone who discovers or guesses the URL can trigger your workflows. Two strategies protect against this: keeping webhook paths unpredictable and validating HMAC signatures inside your workflows.
Use production webhooks with unique IDs
n8n generates two webhook paths for each Webhook node:
- Test URL:
/webhook-test/<id>(active only while the editor is open) - Production URL:
/webhook/<id>(active when the workflow is activated)
The <id> is a UUID by default. Do not change it to something guessable like /webhook/github or /webhook/stripe. The random UUID is your first layer of defense.
Validate HMAC signatures in workflows
Services like GitHub and Stripe sign their webhook payloads with a shared secret. Your n8n workflow should verify this signature before processing the data.
For a GitHub webhook, add an IF node after the Webhook node with this condition:
- In your GitHub repository settings, set a webhook secret (generate one with
openssl rand -base64 32) - In your n8n workflow, add a Code node after the Webhook node:
const crypto = require('crypto');
const secret = $env.GITHUB_WEBHOOK_SECRET;
const signature = $input.first().headers['x-hub-signature-256'];
const body = JSON.stringify($input.first().json);
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
if (signature !== expected) {
throw new Error('Invalid webhook signature');
}
return $input.all();
- Store the secret in n8n's environment, not hardcoded in the workflow. Add it to your
.envfile:
GITHUB_WEBHOOK_SECRET=your-generated-secret-here
And expose it to n8n in docker-compose.yml:
environment:
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}
For Stripe webhooks, the pattern is similar but uses a different header (stripe-signature) and a timing-safe comparison. Check Stripe's webhook signature docs for the current signing scheme.
Hide n8n version information
By default, n8n includes version info in API responses. Disable version disclosure to make reconnaissance harder:
In your .env file:
N8N_VERSION_NOTIFICATIONS_ENABLED=false
In your Nginx config, add inside the server block:
server_tokens off;
Version disclosure helps attackers target known vulnerabilities in specific n8n releases. Hiding it forces them to probe blindly.
How do I tune timeouts and upload limits for n8n?
Some n8n workflows run for minutes, processing large datasets or waiting for external APIs. Nginx's default 60-second timeout will kill these requests. File upload workflows fail if the payload exceeds Nginx's default 1 MB body limit.
In the main location / block of your HTTPS server, add or update:
# Timeout tuning for long-running workflows
proxy_connect_timeout 60s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# Allow file uploads up to 50 MB
client_max_body_size 50m;
proxy_read_timeout 300s gives workflows up to 5 minutes to respond. Adjust this based on your longest workflow execution time. The webhook location block already has its own timeout settings from the rate limiting section.
client_max_body_size 50m allows file uploads up to 50 MB through webhook and editor endpoints. n8n workflows that process CSV imports, image uploads, or document conversions need this.
Test and reload:
sudo nginx -t && sudo systemctl reload nginx
Complete Nginx configuration reference
The full configuration after all changes. Compare it against your file:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=editor:10m rate=30r/s;
server {
listen 80;
listen [::]:80;
server_name n8n.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name n8n.example.com;
ssl_certificate /etc/letsencrypt/live/n8n.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/n8n.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
server_tokens off;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss://n8n.example.com; frame-ancestors 'none'" always;
client_max_body_size 50m;
# Rate limit webhook endpoints
location /webhook/ {
limit_req zone=webhooks burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
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 300s;
proxy_send_timeout 300s;
}
location /webhook-test/ {
limit_req zone=webhooks burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
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;
}
# Restrict editor API to specific IPs
location /rest/ {
allow 203.0.113.50;
allow 10.0.0.0/8;
deny all;
limit_req zone=editor burst=50 nodelay;
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;
}
# Main location - editor frontend and fallback
location / {
limit_req zone=editor burst=50 nodelay;
limit_req_status 429;
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_cache off;
chunked_transfer_encoding off;
proxy_connect_timeout 60s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
How do I verify WebSocket connections work in the n8n editor?
The n8n editor uses a WebSocket connection at /rest/push to receive real-time workflow execution updates. If this connection fails, you will see "Connection lost" banners and the editor will not update after workflow runs.
Open the n8n editor in your browser. Open browser DevTools (F12), go to the Network tab, and filter by "WS" (WebSocket). A connection to wss://n8n.example.com/rest/push with status 101 (Switching Protocols) confirms WebSocket support is working.
From the command line, test the WebSocket upgrade:
curl -sI -H "Upgrade: websocket" -H "Connection: Upgrade" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" https://n8n.example.com/rest/push
A 101 Switching Protocols response confirms WebSocket proxying works. A 200 or 400 means the upgrade headers are not reaching n8n. Go back and check the map directive and the Upgrade/Connection proxy headers.
Something went wrong?
n8n editor shows "Connection lost"
WebSocket proxying is broken. Check that:
- The
map $http_upgrade $connection_upgradeblock exists at the top of the config proxy_set_header Upgrade $http_upgradeandproxy_set_header Connection $connection_upgradeare in thelocation /blockproxy_http_version 1.1is set (WebSocket requires HTTP/1.1)
Check Nginx logs:
sudo journalctl -u nginx -f
"502 Bad Gateway" after restart
n8n container is not running or not listening on port 5678:
docker ps | grep n8n
docker compose logs --tail=50 n8n
Certbot renewal fails
Check the timer is running and test manually:
sudo systemctl status certbot.timer
sudo certbot renew --dry-run
If renewal fails, make sure port 80 is open in UFW and the HTTP server block is still present (Certbot needs it for the HTTP-01 challenge).
"403 Forbidden" from allowed IP
Your IP may have changed. Check your current IP with curl -4 ifconfig.me and update the allow directive in the Nginx config.
Rate limiting too aggressive
If legitimate webhook senders get 429 errors, increase the rate and burst values in your limit_req_zone and limit_req directives. Monitor the rate of incoming requests first:
sudo tail -f /var/log/nginx/access.log | grep webhook
Next steps
- Set up automated backups and updates for your n8n instance (Backup and Update n8n in Production (Docker Compose + PostgreSQL))
- Learn more about Nginx reverse proxy patterns (How to Configure Nginx as a Reverse Proxy)
- Explore TLS configuration in depth (Set Up Let's Encrypt SSL/TLS for Nginx on Debian 12 and Ubuntu 24.04)
- Return to the workflow automation overview (Self-Host Workflow Automation on a VPS with n8n)
Ready to try it yourself?
Deploy your own server in seconds. Linux, Windows, or FreeBSD. →