Nginx Reverse Proxy: How to Set It Up With Nginx Proxy Manager

What is an Nginx reverse proxy and what does it do?
An Nginx reverse proxy is a server layer that receives requests from visitors, forwards them to an application running behind it, and then returns the response to the visitor. Visitors only ever talk to Nginx. Your Node.js app, Python service or Docker container stays hidden, so one IP address can safely serve several applications.
Here is a simple picture. Say your server runs a Node.js app on port 3000 and an admin dashboard on port 8080. You don't want visitors typing port numbers into the address bar. Instead, Nginx listens on ports 80 and 443, checks which domain each request asks for, and hands it to the right application. Think of it as one receptionist at the front door who sends every guest to the correct room.
In this guide we first clarify the concept and how it differs from a forward proxy. Then, based on the official Nginx documentation, we walk through proxy_pass, header forwarding, timeouts and WebSocket settings. Finally, we cover Nginx Proxy Manager: the Docker setup, Let's Encrypt certificates and how to lock down the admin panel. We are a digital marketing and web team, not a hosting company. That's why we keep every technical claim tied to official sources.
What is the difference between a reverse proxy and a forward proxy?
Both terms describe a server that sits in the middle. The difference is who it works for. A forward proxy works on behalf of the client. For example, employees on a corporate network may reach the internet through a single exit point. The destination site then sees the proxy, not the individual employee.
A reverse proxy works on behalf of the server. Visitors don't know how many applications sit behind it, which ports they use or which machine they run on. In short, a forward proxy hides the client, while a reverse proxy hides the server.
| Feature | Forward proxy | Reverse proxy |
|---|---|---|
| Who does it act for? | The client (user, company network) | The server (website, application) |
| What does it hide? | The client identity and IP address | The structure of backend servers |
| Who configures it? | Network admin or the user | Site owner or server admin |
| Typical use | Content filtering, outbound control | SSL termination, routing, caching |
| Example tools | Proxy software such as Squid | Nginx, Nginx Proxy Manager |
This guide focuses on the second column. So whenever you see the word "proxy" below, picture a layer that stands in front of your site, not in front of your visitors.
When do you need an Nginx reverse proxy?
Not every site needs one. A classic WordPress site on shared hosting usually relies on a proxy layer that the host already runs. However, once you manage your own VPS, an Nginx reverse proxy becomes close to essential in these situations:
- One IP, many apps: You run a blog, an API and an admin panel on one server and give each its own domain or subdomain.
- SSL termination: Nginx handles HTTPS encryption, so the app behind it never touches certificates.
- Caching and compression: Nginx keeps popular responses close to the visitor and takes load off the app.
- Security and isolation: App ports stay closed to the outside world. Visitors only see ports 80 and 443.
- Easier maintenance: When you move an app to a new port or a new container, you only update the proxy rule.
On the other hand, spreading traffic across several servers is a separate topic. We cover load balancing in a companion article. Here we stay with the single server reverse proxy scenario.
What should you prepare before setting up a reverse proxy?
A few basic pieces need to be in place first. Otherwise, when something breaks, it gets hard to tell whether the problem sits in the proxy config or in the underlying setup.
- Server and OS: A current Linux distribution and a user with sudo rights. Our Ubuntu Server initial setup guide covers the first steps.
- Nginx itself: A working Nginx that you installed from your package manager. We cover installation in a separate post, so we skip it here.
- DNS record: The A (IPv4) or AAAA (IPv6) record of your domain or subdomain should point to the server. You can confirm it with our DNS lookup tool.
- Backend app: Your application should listen only on a local address such as 127.0.0.1:3000.
- Firewall: Ports 80 and 443 open, app ports closed.
For example, if you want to run a Node.js app as a systemd service, our Node.js deployment guide shows that preparation step by step.
How do you write a basic Nginx reverse proxy config?
The heart of any Nginx reverse proxy setup is the proxy_pass directive. It tells Nginx where to send a matching request. The example below forwards requests for app.example.com to port 3000 on the same server:
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
Save this file in the config directory your distribution uses. Next, test the syntax and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
If the test fails, don't reload. The nginx -t command tells you which file and line has the problem, and it leaves the running config alone. Also, reload applies new settings without dropping open connections. That makes it a better choice than restart on a live site.
What does the trailing slash in proxy_pass change?
This is where most people get stuck. According to the official Nginx proxy module documentation, if you specify a URI in proxy_pass, Nginx replaces the part of the request that matches the location with that URI. Without a URI, Nginx passes the request on in the same form the client sent it.
Here is a concrete case. Say you defined a location /api/ block:
- With
proxy_pass http://127.0.0.1:3000;a request for /api/users reaches the backend as /api/users. - With
proxy_pass http://127.0.0.1:3000/;Nginx swaps /api/ for / and the backend receives /users.
In other words, one slash can decide whether your app returns a 404 or works fine. If your app defines its own routes with the /api prefix, pick the first form. If it knows nothing about that prefix, pick the second. The docs also note that you should leave out the URI when the location uses a regular expression or when proxy_pass contains variables. After every change, test a few different paths in the browser or on the command line.
Why do the Host, X-Forwarded-For and X-Forwarded-Proto headers matter?
By default, Nginx fills the Host header of the upstream request with $proxy_host. So the app sees 127.0.0.1:3000 instead of your domain. The official docs list the defaults as Host $proxy_host and Connection close. If your app behaves differently per domain, this leads to wrong redirects.
That's why we recommend setting three headers explicitly:
- Host $host: Passes the domain the visitor typed. Multi domain apps and frameworks that build absolute links expect it.
- X-Forwarded-For $proxy_add_x_forwarded_for: Appends the visitor IP to the chain. If the incoming request has no such header, the variable simply equals
$remote_addr. - X-Forwarded-Proto $scheme: Tells the app whether the visitor used HTTP or HTTPS.
People skip that last header all the time. When Nginx terminates HTTPS, the app sees a plain HTTP request. Without the header, the app tries to push the visitor back to HTTPS, and you end up in an endless redirect loop. The docs also state one rule that catches many teams. If a location contains even one proxy_set_header, it inherits none of the header settings from the outer level. Therefore, either keep all headers on one level or repeat the full set in every block.
How do you test your Nginx reverse proxy setup?
After you load the config, run a few command line checks before you open a browser. That way, browser cache and remembered redirects can't fool you.
- Check that the app really listens on the local address with
ss -tlnp. - Send a request straight to the backend. If
curl -I http://127.0.0.1:3000fails, the problem lives in the app, not in the proxy. - Next, try the same request through Nginx:
curl -I -H "Host: app.example.com" http://127.0.0.1shows whether the right server block matches, without depending on DNS. - Finally, send an HTTPS request to the real domain and read the status code and redirect target in the response headers.
This order quickly isolates the layer at fault. For instance, if step two works but step three fails, the issue most likely sits in the server_name or proxy_pass line. Also confirm that the X-Forwarded-For value in your app logs shows your own IP address.
How does the real visitor IP reach your application?
An app behind a reverse proxy gets its connection from Nginx, so it always sees 127.0.0.1 as the source. If you rely on visitor IPs for analytics, rate limiting or security logs, the app has to read X-Forwarded-For.
Be careful here, though. A client can put that header into its own request. So tell your app to trust the header only when it comes from proxy addresses you control. The "trusted proxies" setting in frameworks such as Laravel, Express and Django exists exactly for this. Follow the steps in your framework's own docs.
If another layer sits in front of Nginx, such as a CDN or a second proxy, Nginx itself won't see the real address either. In that case you use the set_real_ip_from and real_ip_header directives from ngx_http_realip_module:
set_real_ip_from 192.0.2.10;
real_ip_header X-Forwarded-For;
The address above is only a placeholder. Put the real address range of the layer in front of you there. You can check whether your Nginx build includes the module in the output of nginx -V. As a result, nobody can fake a header and show up in your logs with someone else's IP.
When should you change the proxy timeout settings?
According to the official docs, three core timeouts default to 60 seconds. proxy_connect_timeout limits how long Nginx waits to open a connection to the backend. proxy_read_timeout limits the wait between two reads, and proxy_send_timeout does the same between two writes.
Here is the key detail. The read and send timeouts apply to the silence between two operations, not to the whole response. So a five minute download that streams data the whole time won't break. In contrast, a report query that produces nothing for 70 seconds hits a 504 error with the default settings.
location /reports/ {
proxy_pass http://127.0.0.1:3000;
proxy_connect_timeout 10s;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
In this example we raised the limit only for the slow path. Setting large values site wide lets stuck requests tie up connections for a long time. Also, the docs note that the connect timeout usually can't exceed 75 seconds. For long jobs, the real fix is often a background queue rather than a bigger timeout.
How do you configure an Nginx reverse proxy for WebSockets?
Live chat, notifications and real time dashboards rely on WebSockets. A WebSocket starts as an HTTP request and then switches to a persistent connection through the Upgrade header. However, as the Nginx WebSocket documentation explains, the Upgrade and Connection headers don't pass through a proxy on their own. So you need to forward them explicitly.
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
}
}
}
The map block sends "close" in the Connection header when the client doesn't ask for an upgrade. That way the same location serves both regular and WebSocket requests. The official docs say HTTP 1.1 became the default in recent Nginx releases, while older releases defaulted to 1.0. Writing proxy_http_version 1.1 explicitly is therefore a safe choice on any version.
Also, per the docs, the connection closes if the backend sends no data for 60 seconds. To avoid that, raise proxy_read_timeout or have your app send regular ping frames.
How do SSL termination and caching work on a reverse proxy?
SSL termination means the Nginx reverse proxy layer decrypts HTTPS traffic. The certificate lives only on Nginx, and traffic to the backend stays as plain HTTP inside the server. You don't install a certificate in every app, and you manage renewals in one place. Our SSL certificate guide covers the basics.
Let's Encrypt is the most common way to get a certificate. According to the Let's Encrypt challenge types documentation, the HTTP-01 challenge needs port 80 to be reachable. Wildcard certificates, on the other hand, work only with the DNS-01 challenge. If you need one, see our wildcard SSL guide. After setup, test the chain with our SSL checker.
For caching, Nginx lets you define a disk area and shared memory zone with proxy_cache_path, then use it in a location with proxy_cache. Caching is off by default. Caching pages that belong to logged in users can leak data, so target only content that looks the same for everyone. If you want a fuller HTTP cache, our Varnish Cache article offers a different approach.
What is Nginx Proxy Manager and who is it for?
Nginx Proxy Manager (NPM) is an open source project that lets you manage Nginx through a web interface. It runs as a Docker container. The official docs list these main features: forwarding domains, redirections, streams and 404 hosts, free SSL through Let's Encrypt or your own certificates, access lists with basic HTTP authentication, and user management with permissions and an audit log.
NPM suits people who want to publish a few services without writing config files. For example, if a home server or small VPS runs several apps in Docker, you can add a domain and a certificate for each one in a few clicks. Advanced users can also add custom Nginx settings per host.
Still, if you need complex routing rules, fine tuning under heavy traffic, or config files tracked in Git, hand written Nginx files stay more transparent. If Docker is new to you, start with our Docker guide first.
How do you install Nginx Proxy Manager with Docker?
The official Nginx Proxy Manager setup page suggests starting with a single compose file once Docker and Docker Compose are in place. Per the docs, port 80 serves public HTTP, port 443 serves public HTTPS, and port 81 hosts the admin interface. We suggest a variation that exposes the admin port only to the server itself from day one:
services:
app:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- '127.0.0.1:81:81'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
Save the file as docker-compose.yml in an empty folder, then run this command in the same folder:
docker compose up -d
On first launch, NPM creates keys and a database, so the interface may take a few minutes to come up. Per the docs, the default database is SQLite inside the data folder. You can also connect MySQL, MariaDB or PostgreSQL. The official setup page shows a pinned version tag in its example. In production we recommend a fixed version tag instead of latest, so you decide when updates happen. If you want a UI for your containers, our Portainer setup guide works well alongside this stack.
Which security steps should you take at first login?
Because the admin port listens only on 127.0.0.1, you can't reach the panel directly from outside. Instead, open an SSH tunnel from your own computer:
ssh -L 8181:127.0.0.1:81 user@203.0.113.10
Then open http://127.0.0.1:8181 in your browser. Depending on the version, the panel either asks you to create an admin account on first launch or opens with a default account and asks you to update its details. Either way, don't skip these steps:
- Set your own email address and a long, unique password. Leave no default credentials in place.
- If several people use the panel, give each person a separate account with only the rights they need.
- Add the data and letsencrypt folders to your backup plan. Host definitions and certificates live there.
- Read the official release notes before each update, and take a backup first.
These steps look small. However, if someone takes over the panel, they can point traffic for every one of your domains wherever they like. Put simply, the NPM panel is one of the most valuable doors on your server.
How do you add a proxy host and get a Let's Encrypt certificate?
In the panel, open Hosts, go to Proxy Hosts and add a new entry. The Details tab has four core fields: the domain name, the scheme (http or https), the forward hostname or IP, and the forward port. On the same tab you can turn on WebSocket support and the option that blocks common exploits.
Pay attention to Docker networking here. NPM runs inside a container, so 127.0.0.1 there means the NPM container, not your server. The cleanest fix is to put NPM and your app on the same Docker network. First, create a shared network:
docker network create proxy-net
Then add it as an external network at the end of both compose files:
networks:
default:
name: proxy-net
external: true
Now you can enter the container name instead of an IP in the forward field, plus the port the app uses inside its container. On the SSL tab, request a new Let's Encrypt certificate and turn on the options to force HTTPS and enable HTTP/2. For the HTTP-01 challenge, the domain's DNS record must point to this server and port 80 must stay reachable from outside.
What are access lists in Nginx Proxy Manager for?
Access lists decide who can reach a proxy host. In NPM you can combine two kinds of limits. The first is basic HTTP authentication with a username and password. The second is a set of allow and deny rules based on an IP address or range.
For example, you can open an internal reporting dashboard only to your office's static IP. Or you can put a simple password on a staging site to keep search engines and curious visitors out. Once you create the list, just select it in the settings of the right proxy host.
Even so, don't treat basic authentication as your only line of defense. A password sent without encryption can leak on the network, so always pair access lists with SSL. Keep your app's own login and permission system strong as well. If you want to block brute force attempts at the server level, our Fail2ban guide adds a useful extra layer.
Why is exposing the admin port (81) to the internet risky?
The compose file in the official example publishes port 81 on every network interface. If your server faces the internet directly, the admin login screen becomes public. On top of that, this port runs over plain HTTP by default, so login details travel unencrypted.
There is one more detail that many people miss. The official Docker docs on packet filtering and firewalls state that traffic to published container ports gets diverted before it goes through ufw rules. So "I closed port 81 with ufw" does not guarantee the port is actually closed. Pick one of these approaches instead:
- Bind the port to 127.0.0.1 in the compose file and reach the panel over an SSH tunnel.
- Publish the panel as its own proxy host on your domain, force SSL, and limit it to your IP with an access list.
- Give panel access only through a VPN.
Whichever you pick, test from a different network afterward to confirm the port really stays closed from outside.
Should you choose manual Nginx config or Nginx Proxy Manager?
Both paths use the same engine; the difference lies in how you manage it. The table below sums up the points worth weighing:
| Criteria | Manual Nginx config | Nginx Proxy Manager |
|---|---|---|
| Learning curve | You need to know directives | Quick start through a UI |
| Flexibility | Every setting in your hands | Basics in the UI, advanced settings in a separate field |
| Version control | You can track files in Git | Settings live in a database |
| SSL management | You set it up with a separate tool | Let's Encrypt built into the UI |
| Extra attack surface | None | You must protect the admin panel |
| Best fit | Production, heavy traffic, team work | Home server, small VPS, a few Docker services |
In practice, many teams use both in different environments. For instance, they test quickly with NPM on a staging server and run versioned Nginx files in production. If you want an app platform on your own server, our Coolify guide offers a third option.
What are the most common reverse proxy mistakes?
When an Nginx reverse proxy throws an error, check the Nginx error log first. Most of the time the cause is right there. Here are the most common cases:
- 502 Bad Gateway: Nginx can't reach the backend. The app may be down, it may listen on the wrong port, or name resolution on the Docker network may fail.
- 504 Gateway Timeout: The read timeout ran out before the backend answered. Move the long job to the background or raise the limit only on that path.
- Endless redirects: The X-Forwarded-Proto header is missing, or the app doesn't trust the proxy.
- Mixed content warnings: The app builds HTTP links on an HTTPS page. Again, check how the scheme reaches the app.
- Every visitor shows the same IP: Nginx doesn't forward X-Forwarded-For, or the app doesn't read it.
- WebSockets drop: The Upgrade headers are missing, or the read timeout is too short.
Use this list as a checking order. First the connection, then the headers, and timeouts last. That way you avoid creating new problems by changing settings at random.
When should you leave this to your hosting provider or an expert?
To be honest, not everyone needs to run their own Nginx reverse proxy layer. If your site lives on shared hosting or a managed platform, your provider already runs this layer. Trying to install your own Nginx there may not even work, and it may void your support coverage.
We recommend handing the job to your provider or an experienced sysadmin in these cases:
- The server runs an app that handles payments, health data or personal data.
- Every minute of downtime means real lost revenue.
- You don't feel comfortable with SSH, firewalls and reading logs.
If you want to plan infrastructure together with your own application, our custom software development service helps you get the architecture right from the start. In short, a reverse proxy is a powerful tool, but it also comes with responsibility.



