A reverse proxy is easy to get mostly right. The last 10% matters: certificate chains, forwarded headers, trusted proxies, and tests before live traffic arrives.
This setup was tested on Ubuntu 24.04 LTS and Debian-based systems using systemd. You’ll add TLS termination, redirect HTTP to HTTPS, and preserve request details for the backend. Keep the Windows build in the lab. It requires too much manual work for a production edge.
What Is NGINX Open Source?
NGINX Open Source is a free web server and reverse proxy. Clients connect to NGINX on TCP port 443. NGINX sends each request to an application on 127.0.0.1:8080.
TLS termination splits that route into two connections. NGINX decrypts the public HTTPS request. It then uses plain HTTP for proxy_pass http://... or starts another TLS connection for proxy_pass https://....
Plain HTTP is reasonable when both processes run on the same trusted host. Use HTTPS when backend traffic crosses a shared VLAN, an untrusted network, or a segment covered by an encryption policy. This adds certificate work. Internal TLS offers little protection if you skip identity checks.
Prerequisites
Make sure you have:
- An Ubuntu 24.04 LTS or current Debian server with
sudoaccess - A Windows 11 or Windows Server test host only if evaluating the Windows build
- A hostname such as
app.example.comwith DNS pointing to the proxy - Inbound TCP ports
80and443allowed through the network and host firewalls - A backend application reachable at
127.0.0.1:8080 - A certificate chain and matching private key for
app.example.com - CPU, memory, and disk sized for your expected traffic and TLS load (NGINX publishes no universal minimum)
- SSH access for Linux or an administrative PowerShell session for Windows
- A backup or snapshot before changing an existing production proxy
| Requirement | Example used here |
|---|---|
| Public hostname | app.example.com |
| Linux configuration | /etc/nginx/sites-available/app.example.com |
| Certificate chain | /etc/nginx/ssl/fullchain.pem |
| Private key | /etc/nginx/ssl/privkey.pem |
| Backend | http://127.0.0.1:8080 |
| Public ports | TCP 80 and 443 |
Certificate enrollment and renewal vary by certificate authority. Get both files before you continue. The certificate’s Subject Alternative Name field must include app.example.com. The old Common Name field alone won’t save you.
Step-by-Step Guide
Step 1: Confirm That the Backend Works
Test the application directly from the NGINX host:
curl -I http://127.0.0.1:8080/
A healthy HTTP backend should return something like this:
HTTP/1.1 200 OK
Content-Type: text/html
A 401, 403, or application redirect also proves the service answered. Connection refused means the application is stopped or listening elsewhere. Fix that first. NGINX can’t proxy to wishful thinking.
Step 2: Install NGINX on Linux
On Ubuntu 24.04 or Debian, refresh the package list. Then install NGINX, OpenSSL, and curl:
sudo apt update
sudo apt install nginx openssl curl
Enable NGINX at boot and start it now:
sudo systemctl enable --now nginx
sudo systemctl status nginx --no-pager
The service state should include:
Active: active (running)
Display the installed version:
nginx -v
Example output:
nginx version: nginx/1.x.x
Use nginx -V to see the linked OpenSSL version, build flags, and included modules:
nginx -V
Distribution packages can trail the latest upstream NGINX release. A lower version number doesn’t always mean security fixes are missing. Debian and Ubuntu backport patches. Use the official repository only when you need a specific upstream feature and accept the extra package work.
Step 3: Allow Web Traffic Through the Linux Firewall
If Uncomplicated Firewall is already active, allow SSH and both web ports:
Warning: Confirm that SSH is allowed before changing a remote server firewall. A wrong rule can lock you out.
sudo ufw status
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw status
Expected rules include:
OpenSSH ALLOW
Nginx Full ALLOW
Your cloud firewall, router, or security group must also allow TCP ports 80 and 443. A host rule can’t override a blocked upstream security group.
A Cat6 Ethernet cable and managed 2.5GbE switch can prevent an internal bandwidth bottleneck. Neither has an opinion about firewall rules, unfortunately.
Step 4: Install the Certificate and Private Key
Upload the certificate chain and private key to /tmp/fullchain.pem and /tmp/privkey.pem. Then install them outside the web root:
sudo install -d -m 700 /etc/nginx/ssl
sudo install -m 644 /tmp/fullchain.pem /etc/nginx/ssl/fullchain.pem
sudo install -m 600 /tmp/privkey.pem /etc/nginx/ssl/privkey.pem
sudo ls -l /etc/nginx/ssl
The permissions should look like this:
-rw-r–r– 1 root root … fullchain.pem
-rw——- 1 root root … privkey.pem
fullchain.pem must contain the server certificate and all required intermediate certificates. privkey.pem must contain the matching private key. Mode 600 limits access to root.
Keep both files off shared storage unless the NAS and transfer path are secure. A private key on an open SMB share rather defeats the exercise.
Step 5: Understand the Minimal Reverse Proxy
A basic HTTP-only reverse proxy looks like this:
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
This works for a quick private-network test. Public traffic remains clear text. The backend also gets too little context for reliable URLs or client logs. Use the full configuration below for an exposed service.
Step 6: Create the HTTPS Reverse-Proxy Configuration
Open a new server-block file:
sudo nano /etc/nginx/sites-available/app.example.com
Enter this complete configuration:
server {
listen 80;
listen [::]:80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name app.example.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:8080;
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 listen [::]:80 and listen [::]:443 ssl lines accept IPv6 connections. Keep them only if the host has working IPv6 and your firewall allows ports 80 and 443 over IPv6 too. If DNS publishes an AAAA record for app.example.com but IPv6 is incomplete, some clients will fail while IPv4 tests pass. On an IPv4-only host, remove both [::] lines and don’t publish an AAAA record.
Save in Nano with Ctrl+O, press Enter, and exit with Ctrl+X.
Each forwarded header has a separate job:
| Directive | Purpose |
|---|---|
Host $host | Preserves the requested hostname |
X-Real-IP $remote_addr | Passes the client address seen by NGINX |
X-Forwarded-For $proxy_add_x_forwarded_for | Appends the client to the proxy address chain |
X-Forwarded-Proto $scheme | Tells the application whether the original request used HTTP or HTTPS |
Set the backend to trust these headers only when they come from NGINX or another approved proxy. If it trusts arbitrary forwarding headers, a client can forge an address or alter security checks.
The ssl_protocols line allows TLS 1.2 and TLS 1.3. Don’t copy a fixed ssl_ciphers list from a five-year-old blog post. Test custom lists against your NGINX and OpenSSL build. Test them with supported clients too. A bad cipher list can reject current browsers while keeping obsolete compatibility.
Step 7: Enable the Linux Server Block
Ubuntu and Debian packages usually use sites-available and sites-enabled. Enable the file with a symbolic link:
sudo ln -sfn /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/app.example.com
readlink -f /etc/nginx/sites-enabled/app.example.com
The readlink output should be exactly:
/etc/nginx/sites-available/app.example.com
The -f and -n flags make the command safe to rerun: an existing link with the same name is replaced instead of causing an error, so a stale link can’t hide behind a “File exists” message. If /etc/nginx/sites-enabled/app.example.com is a regular file rather than a link, move it aside before running the command. Don’t create another link under a different name.
If the default site conflicts with your hostname, inspect the loaded configuration before disabling anything:
sudo nginx -T
The configuration layout varies by distribution. Some packages include /etc/nginx/conf.d/*.conf. On those systems, save the blocks as /etc/nginx/conf.d/app.example.com.conf and skip the link command.
Step 8: Validate Before Reloading
Run the configuration test:
sudo nginx -t
Reload only after both checks succeed:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
This command parses the active configuration and checks referenced files. That includes the certificate and private key. It catches bad directives, duplicate listeners, missing files, and several other ways to spoil a quiet afternoon.
Step 9: Reload NGINX and Check Its Status
Apply the tested configuration with a graceful reload:
sudo systemctl reload nginx
sudo systemctl status nginx --no-pager
Expected result:
Active: active (running)
A reload starts new workers with the changed configuration. Existing connections finish on the old workers. That’s better than a restart when the server has live traffic.
A small UPS is worthwhile for a physical reverse-proxy host. Clean power reduces filesystem damage and avoids half-written certificate updates. It won’t fix a bad configuration, but nginx -t already has that job.
Step 10: Test the Redirect and HTTPS Response Externally
Run these tests from another machine. This checks public DNS, firewalls, NAT, and the proxy. A localhost test covers far less ground.
Test HTTP first:
curl -I http://app.example.com
Expected result:
HTTP/1.1 301 Moved Permanently
Location: https://app.example.com/
Then test HTTPS:
curl -I https://app.example.com
A working backend often returns:
HTTP/1.1 200 OK
Server: nginx
Another application status can still be valid. An authenticated service, for example, may return 401 Unauthorized while the proxy and TLS path work correctly.
Inspect the certificate returned through Server Name Indication:
openssl s_client -connect app.example.com:443 -servername app.example.com
Check the subject, issuer, and final verification line:
Verify return code: 0 (ok)
The -servername flag sends the hostname through SNI. Without it, a server with several certificates may return its default certificate. That can send your debugging in the wrong direction.
Step 11: Run NGINX on Windows for a Proof of Concept
The official Windows build is proof-of-concept software for development and testing. Use a conventional Linux server for production workloads.
Open the official NGINX download page. Download the Windows ZIP, right-click it, and select Extract All. Extract it to C:\nginx.

Open Windows Terminal as Administrator and change directories:
Set-Location C:\nginx
.\nginx.exe -v
Copy your test certificate and key into C:\nginx\conf\ssl. Then edit C:\nginx\conf\nginx.conf. Use forward slashes for Windows paths inside the NGINX configuration:
ssl_certificate C:/nginx/conf/ssl/fullchain.pem;
ssl_certificate_key C:/nginx/conf/ssl/privkey.pem;
Validate and start the test instance:
Set-Location C:\nginx
.\nginx.exe -t
Start-Process .\nginx.exe
After a configuration change, test again before you reload:
.\nginx.exe -t
.\nginx.exe -s reload
Expected validation output includes:
syntax is ok
test is successful

Windows lacks the usual systemd service workflow found on Linux. Process control, logs, and upgrades need more manual work. Keep this build in the lab, where that trouble has a small blast radius.
Configuration
Encrypt Traffic to the Backend
Change <a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html" target="_blank" rel="noopener noreferrer">proxy_pass</a> to HTTPS if backend traffic crosses an untrusted network. Do the same if internal policy requires encryption:
location / {
proxy_pass https://10.20.30.40:8443;
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_ssl_verify on;
proxy_ssl_trusted_certificate /etc/nginx/ssl/internal-ca.pem;
proxy_ssl_server_name on;
proxy_ssl_name backend.internal.example.com;
}
HTTPS encrypts the backend connection. proxy_ssl_verify on checks the backend’s identity against /etc/nginx/ssl/internal-ca.pem. Keep both settings. Encryption without identity checks still allows interception by a host with an untrusted certificate.
proxy_ssl_server_name on enables SNI for the upstream TLS connection. You need it when the backend selects a certificate or virtual host by name. SNI alone doesn’t change which name NGINX verifies, though. By default, proxy_ssl_name is the host from proxy_pass, which here is the IP address 10.20.30.40. Most internal certificates are issued to a DNS name, so verification would fail. Set proxy_ssl_name to a name listed in the backend certificate’s Subject Alternative Name field. NGINX then uses that name for both certificate verification and SNI.
Renew Certificates Without Downtime
An expired certificate takes the proxy offline as surely as a broken configuration. Check the current expiry date:
sudo openssl x509 -enddate -noout -in /etc/nginx/ssl/fullchain.pem
The output starts with notAfter= followed by the expiry date. Put that date in your monitoring, not just a calendar.
If you use Let’s Encrypt with Certbot, a deploy hook runs only after a successful renewal. Confirm that the renewal timer your Certbot package installed is active:
systemctl list-timers | grep certbot
Then create /etc/letsencrypt/renewal-hooks/deploy/nginx-reload.sh. It copies the renewed files to the paths this guide uses, tests the configuration, and reloads NGINX:
#!/bin/sh
set -e
install -m 644 "$RENEWED_LINEAGE/fullchain.pem" /etc/nginx/ssl/fullchain.pem
install -m 600 "$RENEWED_LINEAGE/privkey.pem" /etc/nginx/ssl/privkey.pem
nginx -t
systemctl reload nginx
Make it executable and run it once by hand to prove it works:
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/nginx-reload.sh
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/app.example.com /etc/letsencrypt/renewal-hooks/deploy/nginx-reload.sh
Certbot sets $RENEWED_LINEAGE to the renewed certificate’s live directory. With another CA or ACME client, do the same three things after each renewal: copy the new chain and key to /etc/nginx/ssl, run nginx -t, and run systemctl reload nginx. The graceful reload loads the new certificate for new connections without dropping existing ones.
Proxy WebSocket Connections
WebSockets need HTTP/1.1 and explicit upgrade handling.
Add this map at the top of /etc/nginx/sites-available/app.example.com, outside both server blocks. Debian and Ubuntu load that file inside the main http block, where map is allowed:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Then add the WebSocket location to the port-443 server block:
location /socket/ {
proxy_pass http://127.0.0.1:8080;
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;
}
The map sends Connection: upgrade only when the client actually asked for an upgrade, and Connection: close otherwise. That keeps normal HTTP requests in the same location behaving normally. Current upstream NGINX (1.29.7 and later) already uses HTTP/1.1 for proxied connections, but distribution packages are often older, so keep proxy_http_version 1.1. Run sudo nginx -t before every reload.
Tips and Troubleshooting
nginx -t Cannot Load the Certificate
Cause: The certificate path is wrong, the file is missing, or permissions block access.
Check the configured files:
sudo ls -l /etc/nginx/ssl
sudo nginx -t
Confirm that ssl_certificate points to the full chain. Check that ssl_certificate_key points to the matching private key. Don’t reload until the test succeeds. The error usually names the exact path NGINX couldn’t open, which beats guessing.
NGINX Returns 502 Bad Gateway
Cause: The backend is stopped, the port is wrong, a firewall blocks the connection, or the protocol doesn’t match.
Check the backend directly. Then list listening TCP sockets and read the latest 50 NGINX errors:
curl -v http://127.0.0.1:8080/
sudo ss -ltnp
sudo tail -n 50 /var/log/nginx/error.log
If the backend expects TLS, change proxy_pass http://... to proxy_pass https://.... Configure certificate checks as well. If the application runs in a container or on another server, replace 127.0.0.1 with an address NGINX can reach.
Container networking catches people here. 127.0.0.1 inside one container points to that container. It doesn’t point to a neighboring container or the Docker host.
Backend Logs Show the NGINX Address
Cause: The forwarding headers are missing, or the application doesn’t trust them.
Keep X-Real-IP and X-Forwarded-For in the location block. Then add the NGINX address or proxy subnet to the application’s trusted-proxy setting.
NGINX can send the right values, but it can’t force the application to use them. Don’t trust every proxy address. That lets clients submit fake forwarding headers.
The Application Creates HTTP Links or Redirect Loops
Cause: NGINX contacts the backend over HTTP, so the application assumes the client also used HTTP.
Pass:
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
Set the application’s public URL to https://app.example.com. Then mark NGINX as a trusted proxy. These settings also fix many login and OAuth callback loops.
If the loop remains, inspect each Location header with curl -I. Browsers follow redirects fast enough to hide which part issued the bad one.
The Browser Reports Mixed Content
Cause: An HTTPS page loads scripts, styles, images, or API endpoints through absolute http:// URLs.
Pass the forwarded scheme and host headers. Then inspect the generated HTML and browser developer console. Correct the application’s public URL and remove fixed HTTP resource addresses.
NGINX can’t safely rewrite every URL in application output. Response substitution exists, but it’s fragile. It usually hides the application fault.
HTTP Does Not Redirect to HTTPS
Confirm that the matching port-80 block contains:
return 301 https://$host$request_uri;
Then inspect the full configuration NGINX loaded. Validate it, reload, and test again:
sudo nginx -T
sudo nginx -t
sudo systemctl reload nginx
curl -I http://app.example.com
Another default server block may own the hostname or receive the request first. nginx -T prints included files and the main configuration. That makes duplicate blocks easier to spot.
The Wrong Certificate Is Served
Verify DNS and the active virtual host:
dig +short app.example.com
openssl s_client -connect app.example.com:443 -servername app.example.com
The DNS result must point to the intended proxy. The hostname must also appear in server_name and the certificate’s Subject Alternative Name list.
If several sites share port 443, keep the -servername option. Testing only the IP address can return the default certificate. The result may be valid but irrelevant.
WebSocket Connections Fail
Add proxy_http_version 1.1, Upgrade, and Connection directives to the WebSocket location. Then test with the application’s supported WebSocket client or the browser developer tools.
A normal HTTP request doesn’t complete a WebSocket handshake, so curl -I proves little here. Check the NGINX error log and browser response headers for failed upgrade requests.
Wrapping Up
| Step | Action | Applies To |
|---|---|---|
| 1 | Verify the backend | Linux and Windows |
| 2 | Install NGINX | Linux production |
| 3 | Install the certificate | Linux and Windows |
| 4 | Add redirect, TLS, and proxy headers | Linux and Windows |
| 5 | Run nginx -t before reload | Linux and Windows |
| 6 | Test with curl and OpenSSL | External client |
| 7 | Review logs and backend connectivity | Troubleshooting |
NGINX gives you exact control over TLS, headers, locations, and upstream rules. That control brings manual configuration. Caddy takes less work when automatic certificate handling is your main goal.
Test the backend first. Run nginx -t, reload cleanly, and verify from another machine. Cover renewal, trusted proxies, and backend encryption. After that, the proxy should become pleasantly boring.