This guide assumes nginx runs on the Ubuntu host as a normal apt package and n8n runs in Docker with its port bound to 127.0.0.1:5678. If your nginx is also a container, the only line that changes is the proxy_pass target (use the service name), everything else carries over. I run nginx on the host on any server that hosts more than one app, because one nginx with five server blocks is easier to reason about than five proxies in five compose projects, and certbot on the host renews all of them from one timer.
Bind n8n to localhost and set the proxy variables
In your compose file the n8n service should publish its port on the loopback address only and carry the variables that describe its public address. The relevant lines, assuming the domain is n8n.example.com:
services:
n8n:
image: n8nio/n8n:2.38.5
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
environment:
- N8N_HOST=n8n.example.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_EDITOR_BASE_URL=https://n8n.example.com
- N8N_WEBHOOK_URL=https://n8n.example.com/
- N8N_PROXY_HOPS=1
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
The database, timezone and encryption key variables are left out here for brevity; the full production compose with Postgres is in the n8n install guide for Ubuntu 24.04, and this article only replaces the Caddy part of it. N8N_WEBHOOK_URL is the 2.35+ name of WEBHOOK_URL. N8N_PROXY_HOPS=1 makes n8n trust one layer of forwarded headers. If your webhook URLs still come out wrong in the editor after all this, the cause is on the n8n side and the n8n webhook URL troubleshooting guide covers every variable involved, so I won't repeat it here. Restart with docker compose up -d after editing and confirm the bind with ss -ltnp | grep 5678. The line should show 127.0.0.1:5678; if it shows 0.0.0.0:5678 or *:5678 the ports entry didn't take.
Install nginx and certbot on Ubuntu 24.04
sudo apt update
sudo apt install -y nginx certbot
nginx -v
Ubuntu 24.04 ships nginx 1.24.0. That version matters for one line later: the http2 directive that replaced the listen ... http2 parameter arrived in nginx 1.25.1, so the stock package still needs the older form. Certbot from apt is 2.9 on noble and works fine; the snap is newer but I don't want snapd on a server for one tool.
Nginx server block for n8n
The order here is deliberate. Write a plain HTTP block, get the certificate through it, then add the HTTPS block that references the certificate files. Writing the HTTPS block first fails nginx -t because the files don't exist yet.
HTTP block for the ACME challenge
Create /etc/nginx/sites-available/n8n:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name n8n.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
The map at the top is used by the HTTPS block later: when a request carries an Upgrade header (the editor's WebSocket connection) nginx sends Connection: upgrade upstream, and for ordinary requests it sends close. That's the shape nginx's own WebSocket proxying docs use, and it avoids sending Connection: upgrade on every plain request the way a hard-coded header does.
sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Get a Let's Encrypt certificate with certbot
sudo mkdir -p /var/www/html
sudo certbot certonly --webroot -w /var/www/html -d n8n.example.com --deploy-hook "systemctl reload nginx"
The redirect in the port 80 block lives inside location / and not at server level on purpose: a server-level return runs before nginx picks a location, which would bounce the ACME request to an HTTPS block that doesn't exist yet. With the webroot authenticator certbot drops the challenge file under /var/www/html/.well-known/acme-challenge/, Let's Encrypt fetches it over port 80, and the certificate lands in /etc/letsencrypt/live/n8n.example.com/ without certbot touching your server block. The deploy hook is saved into the renewal config, so every automatic renewal reloads nginx and the new certificate goes live at once. Without the hook, a certonly renewal leaves nginx holding the expired one until something else restarts it, which I found out the way everyone does.
Renewal itself is handled by certbot.timer, which the apt package enables. Check it with systemctl list-timers certbot.timer and dry-run a renewal with sudo certbot renew --dry-run once the HTTPS block below is in place.
HTTPS server block with WebSocket and forwarded headers
Append this to the same file, below the port 80 block:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
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;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
client_max_body_size 16m;
location /mcp/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Connection '';
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-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
gzip off;
chunked_transfer_encoding off;
proxy_read_timeout 3600s;
}
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-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
sudo nginx -t && sudo systemctl reload nginx
Open https://n8n.example.com, log in, open a workflow and run it manually. If the run status updates live in the editor, the WebSocket path works. If you installed nginx from nginx.org instead of Ubuntu's package and have 1.25.1 or newer, replace the two listen 443 ssl http2; lines with listen 443 ssl; and add http2 on; in the server block; the old parameter still works but nginx logs it as deprecated.
No trailing slash on proxy_pass. With proxy_pass http://127.0.0.1:5678/; nginx rewrites the URI relative to the location, which is harmless for location / and wrong for anything more specific, so I never write the slash and don't have to think about it.
Proxy buffering, body size and timeouts for n8n
client_max_body_size is the request size nginx accepts before returning 413. n8n's own limit is N8N_PAYLOAD_SIZE_MAX, 16 MiB by default according to the endpoints environment variables page, so 16m in nginx matches it; raise both together if a webhook posts bigger JSON. Form-data file uploads have a separate n8n limit, N8N_FORMDATA_FILE_SIZE_MAX, 200 MiB by default, so a Webhook node that receives file uploads needs client_max_body_size 200m; or the proxy rejects what n8n would have accepted.
proxy_buffering off makes nginx pass response bytes through as n8n writes them, with no waiting for the whole response. n8n's push channel, chat responses and MCP streams all depend on that. proxy_read_timeout 3600s covers a request whose response takes a long time to start, which is what happens when a Webhook node is set to respond "when last node finishes" and the workflow runs for minutes; the nginx default of 60 seconds returns a 504 to the caller while n8n is still working. I have not measured what disabling buffering costs on a webhook-heavy instance, on ours the difference wasn't visible, but a proxy handling thousands of small responses a minute is a different case and I'd test before assuming.
proxy_cache off is redundant when no cache zone is defined. I keep it because the config gets copied into servers that do define one.
Fix "Connection lost" in the n8n editor behind nginx
The editor talks to the backend over a persistent connection at /rest/push, and since N8N_PUSH_BACKEND defaults to websocket that connection needs the HTTP/1.1 upgrade to go through the proxy. A server block without proxy_http_version 1.1 and the Upgrade / Connection headers shows the editor loading fine, then a "Connection lost" banner a few seconds later and no live execution updates. The block above already has all three. If you copied a config from somewhere else, those are the lines to compare, and the community thread that settled this reached the same three lines after a lot of guessing. A second cause with the same banner is a proxy timeout shorter than the push connection's idle time, which the 3600s read timeout above avoids.
Setting N8N_PUSH_BACKEND=sse is a workaround for a proxy you can't change, since server-sent events ride on a normal HTTP response. It still needs buffering off.
MCP Server Trigger behind nginx
The separate location /mcp/ block exists because n8n's MCP Server Trigger docs ask for it: buffering off, gzip off, chunked transfer encoding off and an empty Connection header so nothing inherited from elsewhere in the config interferes with the SSE or streamable HTTP transport. If you don't expose any MCP endpoints, delete that block. Nothing else depends on it.
Caddy instead of nginx
If n8n is the only thing on the server, Caddy in the same compose project does everything above in four lines: a site block with reverse_proxy n8n:5678 and flush_interval -1. Certificates, renewals, the redirect from 80 and the forwarded headers are automatic. The reason to pick nginx anyway is that you already run it, want the same config style across servers, or need things like a CrowdSec bouncer in front of several apps; the CrowdSec AppSec setup for nginx is where I'd go next for that on a public n8n editor. On our own box it's nginx for exactly that reason, and the n8n side never noticed the difference.

