You need an Nginx reverse proxy setup when one server runs several applications: a website, an API, an admin panel. Each one listens on its own port on 127.0.0.1, while Nginx accepts all requests on ports 80 and 443 and passes them to the right application based on the domain name. In the end every site has its own Let's Encrypt certificate, HTTP redirects to HTTPS, WebSocket and large file uploads work, and certificates renew on their own.
Tested on Ubuntu 24.04 and 26.04: Nginx 1.24.0 and 1.28.3, certbot 2.9.0 and 4.0.0 from the Ubuntu repositories. The commands in this guide obtain certificates from the real Let's Encrypt. The test environment has no public domain, so we tested issuance, renewal and the dry-run renewal against Pebble, a test ACME server from Let's Encrypt that validates the domain the same way, with a request to port 80. Replace 203.0.113.10 with your server's address, and example.com and api.example.com with your own domains.
Prerequisites
- A server running Ubuntu 24.04 or 26.04, a user
adminwithsudo, ports 80 and 443 open in the firewall — see initial server setup. - Domains whose A records point to the server. The example uses three:
example.com,www.example.comandapi.example.com. - Applications that listen only on the local address: the website on
127.0.0.1:3000, the API on127.0.0.1:8000. How to run a Node.js application this way is covered in the article on deploying Next.js; for a Docker container, publish the port with-p 127.0.0.1:3000:3000.
How a reverse proxy works
https://example.com ─┐ ┌─> 127.0.0.1:3000 website
├─> Nginx :80 :443 ┤
https://api.example.com ─┘ └─> 127.0.0.1:8000 API
A reverse proxy is a server that accepts requests on behalf of an application and forwards them to it. Nginx decides where to send a request based on the Host header and the server_name directive. It also holds the certificates, redirects HTTP to HTTPS and limits request size. The applications are not reachable from outside: they listen on 127.0.0.1, and the only way to them is through Nginx.
Step 1. Install Nginx
sudo apt update
sudo apt install -y nginx
nginx -v
Nginx from the Ubuntu repository starts right away and is enabled at boot. The version is nginx/1.24.0 on Ubuntu 24.04 and nginx/1.28.3 on 26.04; security updates arrive together with the system updates.
Check: systemctl is-active nginx prints active, and curl -sI http://203.0.113.10/ | head -1 from your computer prints HTTP/1.1 200 OK (the default Nginx page).
Step 2. Common proxy settings: headers and WebSocket
Settings shared by all sites go into two files. The first one is for WebSocket:
sudo tee /etc/nginx/conf.d/websocket.conf > /dev/null <<'EOF'
# For WebSocket, pass Connection: upgrade to the application;
# for regular requests, Connection: close
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
EOF
The second one holds the headers Nginx passes to the application:
sudo tee /etc/nginx/snippets/proxy.conf > /dev/null <<'EOF'
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
EOF
What matters here:
Host— without this line the application receivesHost: 127.0.0.1:3000instead of the domain and may build wrong links.X-Real-IPandX-Forwarded-For— the visitor's address. Without them the application sees every request as coming from127.0.0.1, and IP-based limits and logs stop working.X-Forwarded-Proto— the protocol the visitor used (httporhttps). From it the application knows the page was opened over HTTPS, even though the request from Nginx to the application travels over plain HTTP.proxy_http_version 1.1,Upgrade,Connection— required for WebSocket. By default Nginx talks to the application over HTTP/1.0 and does not forward theUpgradeandConnectionheaders, so without these lines a WebSocket connection will not be established. This is what the Nginx documentation recommends.
Ubuntu already ships a similar file, /etc/nginx/proxy_params, but it has no WebSocket settings, so we made our own.
Step 3. A server block for each site
A server block is a server { … } block with the settings of one site. Create one file per site. The main site, with WebSocket at /ws/:
sudo tee /etc/nginx/sites-available/example.com > /dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
client_max_body_size 20m;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
location / {
include snippets/proxy.conf;
proxy_pass http://127.0.0.1:3000;
}
location /ws/ {
include snippets/proxy.conf;
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
}
EOF
The API, with large uploads and long requests:
sudo tee /etc/nginx/sites-available/api.example.com > /dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name api.example.com;
client_max_body_size 100m;
proxy_read_timeout 300s;
location / {
include snippets/proxy.conf;
proxy_pass http://127.0.0.1:8000;
}
}
EOF
Enable the sites, disable the default page and apply the settings:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
nginx -t checks the syntax and prints test is successful; only then does reload apply the settings. On reload Nginx does not drop current connections: in our test, 600 consecutive requests sent while the configuration was reloading all got a 200 response, and an open WebSocket connection kept working.
What the limits mean:
client_max_body_size— the maximum request size, for example an uploaded file. The default is 1 MB; anything larger gets413 Request Entity Too Largefrom Nginx. In our test a 30 MB file sent toexample.com(limit 20 MB) got413, while onapi.example.comit reached the application.proxy_read_timeout— how long to wait for the application's response (more precisely, for the pause between parts of the response). The default is 60 seconds, after which Nginx responds with504 Gateway Time-out. A request that took the application 65 seconds to answer got504onexample.comexactly after 60 seconds, and a200response onapi.example.comwith its 300-second limit.proxy_connect_timeout 5s— how long to wait for a connection to the application. An application on the same server connects instantly; if it is not running, it is better to return an error quickly than to wait the default 60 seconds./ws/with a 1-hour timeout — for WebSocket,proxy_read_timeoutmeans how long a connection may stay silent. With 60 seconds Nginx would close every connection that had no messages for a minute. In our test a connection that was silent for 70 seconds kept working.
Note the trailing slash in location /ws/: Nginx redirects a request to /ws without it to /ws/ with a 301 code. If the client connects to /ws, write exactly that: location /ws {.
Check — from your computer, with the domains already pointing to the server:
curl -sI http://example.com/ | head -1
curl -sI http://api.example.com/ | head -1
Both responses come from your applications, not from the default Nginx page.
Step 4. Install certbot
Certbot is a program that obtains Let's Encrypt certificates and renews them. Install it from the Ubuntu repository together with the Nginx plugin:
sudo apt install -y certbot python3-certbot-nginx
certbot --version
The version is certbot 2.9.0 on Ubuntu 24.04 and certbot 4.0.0 on 26.04. The package enables the certbot.timer renewal timer on its own. A newer certbot (the latest is 5.8, from September 2026) can be installed via snap following the certbot instructions; all the commands below are the same for it.
Step 5. Obtain certificates: certbot --nginx
Issue a certificate for each site separately:
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot --nginx -d api.example.com
On the first run certbot asks for an email address and asks you to accept the Let's Encrypt terms. Let's Encrypt no longer sends certificate expiration emails — that service ended in June 2025, so the timer keeps track of renewals (step 8). Next, certbot proves you control the domain: it places a temporary file that Let's Encrypt downloads from http://example.com/.well-known/acme-challenge/…. That is why port 80 must be open.
Separate certificates are more convenient than one shared certificate: if DNS breaks for one domain, the other certificate still renews.
The --nginx plugin edits the site files itself. In example.com it:
- replaced
listen 80withlisten 443 ssland added the certificate paths and common TLS settings (options-ssl-nginx.conf,ssl-dhparams.pem); - added a second
serverblock on port 80 that responds with a301redirect to HTTPS. You don't need to set up the redirect separately: in our test both certbot versions added it without asking.
To see the result, run cat /etc/nginx/sites-available/example.com; certbot's lines are marked with a # managed by Certbot comment.
Check:
curl -sI http://example.com/page | grep -iE '^HTTP|^location'
curl -sI https://example.com/ | head -1
sudo certbot certificates
The first command shows HTTP/1.1 301 Moved Permanently and Location: https://example.com/page, the second HTTP/1.1 200 OK. certbot certificates lists both certificates with their domains, paths and validity period: Let's Encrypt currently issues them for 90 days.
Step 6. Enable HTTP/2
Certbot turns on HTTPS but not HTTP/2, the faster protocol browsers use over HTTPS. The syntax depends on the Nginx version.
On Ubuntu 26.04 (Nginx 1.28), add this line to the server block with listen 443 ssl:
http2 on;
On Ubuntu 24.04 (Nginx 1.24) the http2 on directive does not exist yet, so add http2 to both listen lines:
listen [::]:443 ssl http2 ipv6only=on; # managed by Certbot
listen 443 ssl http2; # managed by Certbot
Apply: sudo nginx -t && sudo systemctl reload nginx. Check: curl -sI https://example.com/ | head -1 prints HTTP/2 200.
Step 7. Security headers
sudo tee /etc/nginx/snippets/security-headers.conf > /dev/null <<'EOF'
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
EOF
Include it in every server block with listen 443 — for example, right after server_name:
server_name example.com www.example.com;
include snippets/security-headers.conf;
Also hide the Nginx version in responses. On Ubuntu 24.04 this line in nginx.conf is commented out, on 26.04 it reads server_tokens build;; the command fixes both cases:
sudo sed -i -E 's/^(\s*)#?\s*server_tokens .*/\1server_tokens off;/' /etc/nginx/nginx.conf
sudo nginx -t && sudo systemctl reload nginx
What the headers do:
Strict-Transport-Security(HSTS) — for a year the browser opens the site only over HTTPS, even if you typehttp://. Turn it on once HTTPS already works: for visitors who have already been to the site, it cannot be undone quickly. AddincludeSubDomainsonly if all subdomains work over HTTPS.X-Content-Type-Options: nosniff— the browser does not try to guess the file type from its contents.X-Frame-Options: SAMEORIGIN— the site cannot be embedded in a frame on someone else's page.Referrer-Policy— other sites receive only the domain, not the full page URL.always— the headers are added to error responses too.
add_header has a quirk: if a location has its own add_header directive, the headers from the server block do not apply there. In that case repeat include snippets/security-headers.conf; inside that location.
Check:
curl -sI https://example.com/ | grep -iE '^server|^strict|^x-|^referrer'
All four headers should be present, and Server: nginx without a version number.
Alternative: certbot leaves the Nginx configuration alone (webroot)
If your Nginx configuration is kept in git or deployed by Ansible, it is more convenient when certbot only obtains certificates and you write the server blocks yourself. That is what webroot mode is for: certbot places the validation file in a directory, and Nginx serves it. We'll show it on api.example.com, for a new site that has no certificate yet.
First, the block on port 80: validation files are served from the directory, everything else redirects to HTTPS:
sudo mkdir -p /var/www/letsencrypt
sudo tee /etc/nginx/sites-available/api.example.com > /dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name api.example.com;
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://$host$request_uri;
}
}
EOF
sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --webroot -w /var/www/letsencrypt -d api.example.com \
--deploy-hook "systemctl reload nginx"
Certbot remembers --deploy-hook and runs it after every renewal: without a reload, Nginx would keep serving the old certificate. The --nginx plugin reloads Nginx itself; webroot mode does not.
Add the block for port 443 only after the certificate has been obtained: if the certificate file does not exist yet, nginx -t fails with cannot load certificate. Append to the same file:
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # on Ubuntu 24.04, replace this line with: listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
include snippets/security-headers.conf;
client_max_body_size 100m;
proxy_read_timeout 300s;
location / {
include snippets/proxy.conf;
proxy_pass http://127.0.0.1:8000;
}
}
And apply: sudo nginx -t && sudo systemctl reload nginx. You don't need to specify TLS versions: in our test on both Ubuntu versions the server accepted TLS 1.2 and 1.3 and rejected the obsolete 1.0 and 1.1.
Check: curl -sI https://api.example.com/ | head -1 prints HTTP/2 200, and /etc/letsencrypt/renewal/api.example.com.conf contains the lines authenticator = webroot and renew_hook = systemctl reload nginx.
Step 8. Automatic renewal: certbot.timer and renew --dry-run
The certbot package runs renewal from a systemd timer twice a day, with a random delay. Certbot renews only the certificates that are close to expiring: version 2.9 does it 30 days before expiration, version 4.0 and newer when a third of the validity period remains (certbot changelog).
systemctl list-timers certbot.timer
sudo certbot renew --dry-run
The first command shows when the timer fires next. The second is a dry-run renewal: certbot goes through all the steps against the Let's Encrypt test server (staging) without touching the real certificates and without running --deploy-hook. The expected result:
Processing /etc/letsencrypt/renewal/api.example.com.conf
Simulating renewal of an existing certificate for api.example.com
Processing /etc/letsencrypt/renewal/example.com.conf
Simulating renewal of an existing certificate for example.com and www.example.com
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/api.example.com/fullchain.pem (success)
/etc/letsencrypt/live/example.com/fullchain.pem (success)
Run --dry-run after any change to the site configuration: if renewal is broken, you find out right away, not when the certificate expires.
64-day certificates starting February 10, 2027
Let's Encrypt has announced that starting February 10, 2027, certificates will be issued for 64 days instead of 90. The staging server switches to the new validity period on October 14, 2026. Domain validation can be reused for 10 days instead of 30 — certbot validates the domain on every renewal anyway.
If certbot.timer handles renewals, you don't need to do anything. We issued 64-day certificates on the test server: certbot 2.9 and 4.0 showed VALID: 63 days and considered them not due for renewal (Certificate not yet due for renewal), while a certificate close to expiration was renewed and certbot reloaded Nginx on its own. Check your scripts if renewal runs on a schedule with a hard-coded number of days, or once every two or three months with --force-renewal — such a setup is better replaced with the timer. Let's Encrypt recommends renewing at about two thirds of the validity period and preparing for 45-day certificates in 2028.
Step 9. Check everything with curl
From your computer:
curl -sI http://example.com/ | grep -iE '^HTTP|^location'
curl -sI https://www.example.com/ | head -1
curl -sI https://api.example.com/ | head -1
curl -svo /dev/null https://example.com/ 2>&1 | grep -E 'expire date|issuer'
curl -si --http1.1 -m 3 https://example.com/ws/ \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' | head -1
Expected: a 301 redirect to https://, HTTP/2 200 responses, the certificate issuer O=Let's Encrypt with an expiration date 90 days out, and HTTP/1.1 101 Switching Protocols for WebSocket. The last command ends on a timeout after 3 seconds: the WebSocket connection stayed open, which is expected.
The application should see the visitor's real address and protocol. Check its logs or print the X-Forwarded-For and X-Forwarded-Proto headers: they should contain your IP and https.
Troubleshooting
| Symptom | What to do |
|---|---|
502 Bad Gateway |
The application is not running or listens on a different port. Check ss -ltnp | grep -E ':3000|:8000' and sudo tail /var/log/nginx/error.log |
504 Gateway Time-out after exactly 60 seconds |
The application takes longer than proxy_read_timeout to respond. Increase it for the site or for a single location |
413 Request Entity Too Large |
The request is larger than client_max_body_size (1 MB by default). Increase it in the server block |
WebSocket does not connect, the application responds with 400 or 426 |
The Upgrade and Connection headers or proxy_http_version 1.1 are missing. Check that the location includes snippets/proxy.conf |
| WebSocket drops after about a minute of silence | This location uses proxy_read_timeout 60s. Increase it or set up a ping every 30 seconds in the application |
A request to /ws gets 301 |
location /ws/ has a trailing slash. Connect to /ws/ or change it to location /ws |
The application builds http:// links or goes into an endless redirect |
The application does not trust X-Forwarded-Proto. Enable proxy trust: in Express, app.set('trust proxy', 'loopback'); in Django, SECURE_PROXY_SSL_HEADER |
nginx -t: cannot load certificate "/etc/letsencrypt/live/…" |
The 443 block was added before the certificate was obtained. Remove it, obtain the certificate, put the block back |
certbot: Timeout during connect or unauthorized |
The A record does not point to the server, or port 80 is closed. On your computer, run dig +short example.com; on the server, sudo ufw status |
certbot: too many certificates (5) already issued for this exact set of identifiers |
You hit a Let's Encrypt rate limit: no more than 5 certificates for the same set of domains in 7 days. Use --dry-run for experiments |
| Some pages lack the security headers | That location has its own add_header. Repeat include snippets/security-headers.conf; in it |
What's next
- Caddy instead of Nginx: HTTPS without manual setup — the same result without certbot, if you don't need Nginx's fine-grained settings.
- Deploying Next.js on a VPS — an application behind this proxy, a systemd service and static file caching in Nginx.
- Docker Compose in production — how to publish container ports only on
127.0.0.1so that only the proxy can reach them. - Nginx configurations for specific applications: WordPress, Django and FastAPI; Nginx logs and their rotation — logs on the server.