n8n Nginx Reverse Proxy Setup with SSL
You got n8n running on port 5678. Now you need it on a real domain with HTTPS. This guide covers the complete nginx reverse proxy config for n8n - the one that actually handles websockets, webhooks, and doesn't break after a few hours.
If you followed our n8n Docker Postgres setup, this is the next step.
Why n8n Needs a Proper Reverse Proxy Config
N8n is not a standard HTTP application. It uses persistent websocket connections for the editor UI, and those break silently if your nginx config doesn't handle upgrades. Most tutorials miss this and give you a config that looks like it works - until you leave a workflow running and come back to a disconnected editor.
The other issue is webhooks. If WEBHOOK_URL doesn't match your actual public URL, external services like Stripe or GitHub will send payloads that n8n receives but can't route correctly.
This config handles both.
What You Need
-
A VPS with Docker and Docker Compose installed
-
A domain name pointing to your server's IP
-
Port 80 and 443 open in your firewall
-
n8n already running on port 5678 (or we'll set it up together below)
Option 1: Nginx as a System Service + Certbot
If you're running nginx directly on the host (not in Docker), this is the cleanest approach.
Install Nginx and Certbot
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
Get Your SSL Certificate
sudo certbot --nginx -d n8n.yourdomain.com
Certbot will modify your nginx config automatically. But for n8n specifically, you want full control over the config - so let certbot get the certificate, then replace the config with the one below.
The Nginx Config for n8n
Create or edit /etc/nginx/sites-available/n8n:
# WebSocket upgrade map - required for n8n editor
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name n8n.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name n8n.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/n8n.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/n8n.yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
# Increase timeouts for long-running workflows
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
location / {
proxy_pass http://localhost:5678;
proxy_http_version 1.1;
# WebSocket headers - do not remove these
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Standard proxy headers
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;
# Required for n8n to generate correct webhook URLs
proxy_set_header X-Forwarded-Host $host;
# Disable buffering for streaming responses
proxy_buffering off;
}
}
Enable it and reload:
sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
n8n Environment Variables
N8n needs to know its public URL. Set these in your n8n environment (.env file or docker-compose):
N8N_HOST=n8n.yourdomain.com
N8N_PROTOCOL=https
WEBHOOK_URL=https://n8n.yourdomain.com/
N8N_PROXY_HOPS=1
N8N_PROXY_HOPS=1 tells n8n it's sitting behind one proxy layer, so it reads the X-Forwarded-Proto header correctly. Without this, n8n may generate http:// webhook URLs even when running on HTTPS.
Option 2: Full Docker Compose Stack (Nginx + n8n + Postgres + Certbot)
If you want everything containerized, this is the complete setup.
File Structure
/opt/n8n/
├── docker-compose.yml
├── .env
├── nginx/
│ └── n8n.conf
└── certbot/
├── conf/
└── www/
.env File
DOMAIN=n8n.yourdomain.com
CERTBOT_EMAIL=you@yourdomain.com
POSTGRES_USER=n8n
POSTGRES_PASSWORD=change_this_to_something_strong
POSTGRES_DB=n8n
N8N_ENCRYPTION_KEY=generate_with_openssl_rand_hex_32
Generate the encryption key:
openssl rand -hex 32
docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 5s
timeout: 5s
retries: 10
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: unless-stopped
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: ${DOMAIN}
N8N_PROTOCOL: https
WEBHOOK_URL: https://${DOMAIN}/
N8N_PROXY_HOPS: 1
EXECUTIONS_DATA_PRUNE: true
EXECUTIONS_DATA_MAX_AGE: 336
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/n8n.conf:/etc/nginx/conf.d/n8n.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- n8n
certbot:
image: certbot/certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
volumes:
postgres_data:
n8n_data:
nginx/n8n.conf
This is the nginx config for the containerized version. It handles the ACME challenge for certbot renewal and proxies everything else to n8n:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name n8n.yourdomain.com;
# Required for certbot domain verification
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name n8n.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/n8n.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/n8n.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
add_header Strict-Transport-Security "max-age=31536000" always;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
location / {
proxy_pass http://n8n: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_set_header X-Forwarded-Host $host;
proxy_buffering off;
}
}
Note: in the Docker version, proxy_pass points to http://n8n:5678 (the service name), not localhost.
Getting Your First Certificate
Nginx won't start if it references certificates that don't exist yet. Handle this with a two-step init:
Step 1 - Start only nginx in HTTP-only mode (comment out the HTTPS server block temporarily), then run certbot:
docker compose up -d nginx
docker compose run --rm certbot certonly --webroot \
-w /var/www/certbot \
-d n8n.yourdomain.com \
--email you@yourdomain.com \
--agree-tos \
--no-eff-email
Step 2 - Uncomment the HTTPS block, then bring everything up:
docker compose up -d
Certbot runs on a 12-hour loop in the background and auto-renews before expiry.
Common Issues and Fixes
WebSocket connection drops / editor disconnects randomly
The map $http_upgrade $connection_upgrade block at the top is required. If it's missing, or the proxy_set_header Upgrade and proxy_set_header Connection lines are absent, the editor will disconnect after the first websocket timeout. Verify your config with nginx -t.
Webhooks generating http:// URLs instead of https://
Check two things: N8N_PROXY_HOPS=1 must be set, and N8N_PROTOCOL=https with a matching WEBHOOK_URL. Restart n8n after any env var change.
413 Request Entity Too Large
Workflows that process files or large JSON payloads will fail if nginx's client body size limit is too low. Add to your server block:
client_max_body_size 50m;
502 Bad Gateway on startup
Almost always means n8n hasn't finished starting, or the postgres healthcheck hasn't passed. Wait 10-15 seconds. If it persists:
docker compose logs -f n8n
Look for Editor is now accessible via: - that's when n8n is ready.
Certificate not found on nginx start
If you're using the Docker Compose setup and nginx fails with a certificate-not-found error, you need to do the two-step init above. The SSL server block requires certificates that already exist on disk.
Verifying the Setup
Three checks before you call it done:
- Open
https://n8n.yourdomain.com- login screen should load with a valid SSL cert (green padlock). - Create a test webhook trigger node. The URL shown should start with
https://. - In browser dev tools (Network tab), reload the n8n editor and confirm there's an active
wss://WebSocket connection.
All three passing means the proxy is working correctly.
Need This Done For You?
If you're setting up n8n for a business and want a production-ready deployment without debugging nginx configs, n8n Logic handles self-hosted n8n setup and maintenance for agencies and SMBs. We've done this setup dozens of times and can have a hardened instance running on your infrastructure same-day.
FAQ
Can I use a subdirectory instead of a subdomain (like domain.com/n8n/)?
Technically yes, but it's painful. N8n has assumptions baked in about its base URL, and subdirectory setups require N8N_PATH and N8N_EDITOR_BASE_URL which can conflict with webhook routing. Use a subdomain.
Do I need nginx if I'm using Traefik or Caddy?
No. Both are solid alternatives. Caddy is simpler if you don't have nginx experience - automatic HTTPS, one-file config. Traefik makes sense if you're running multiple services and want label-based routing. Nginx is the most widely understood option.
My n8n instance is on a different machine from nginx. What changes?
Just the proxy_pass address. Instead of localhost:5678 or n8n:5678, use the internal IP: http://192.168.1.x:5678. Make sure port 5678 is accessible from your nginx host.
How do I renew the certificate manually?
System nginx + certbot: sudo certbot renew. Docker Compose: certbot handles it automatically. Force a manual renewal with: docker compose run --rm certbot renew.