Lesson 10 · 20 min read
Nginx: Reverse Proxy
The receptionist in front of your app: TLS termination, static files, compression, and why port 3000 never goes public.
What you'll learn in this lesson
- Concept: the receptionist
- Why port 3000 should never be public
- The six jobs Nginx does for you
- Install and first look
- How an Nginx config is organised
- The reverse-proxy config, line by line
- Why the forwarded headers matter
- Compression, caching and upload limits
- Protecting the app: rate limits and security headers
- HTTPS with Certbot
- Nginx behind a load balancer
- Multi-tenant subdomains
- Close the door on 3000
- Operating Nginx: test, reload, logs
- 502, 504, 413 and other errors decoded
- Interview corner
- Practice task before Lesson 11
- Conclusion
Concept
Nginx (say “engine-x”) is a web server program. Most often it does one special job: it works as a reverse proxy. A reverse proxy is a server that stands in front of your app. It takes every request from the internet and passes it on to the app.
A port is a numbered door on a computer. Nginx listens on the public doors 80 (HTTP) and 443 (HTTPS). Your real app stays private on port 3000 and never meets the internet.
In a hotel, guests do not walk into the kitchen and shout their order at the chef. They talk to the receptionist. The receptionist checks who they are, sends troublemakers away, writes the request down clearly, and passes it to the right team. The chef (Node) can focus on cooking. Nginx is the receptionist. Your app is the chef.
- Browserhttps://yourapp.com/dashboard
- port 443 — the only public doorNginxends TLS · serves /static files itself · gzips · rate-limits · adds headers
- proxy_pass http://127.0.0.1:3000 — privateNext.js / Node appsees a plain HTTP request with X-Forwarded-* headers
- response goes back the same way, compressed by NginxBrowsergets the page — never knew port 3000 existed
The word reverse tells it apart from a normal (forward) proxy. A forward proxy stands in front of clients (the users) and hides who they are. A reverse proxy stands in front of servers and hides which server really answered.
Why port 3000 should never be public
In Lesson 7 you opened http://SERVER_IP:3000 directly. That was only a test door. Now you must close it. Node.js is the program that runs JavaScript on a server, and Next.js runs on top of it. They are good at running your app logic. They are not built to face the open internet alone:
- No HTTPS of their own. HTTPS is HTTP with encryption, so nobody on the network can read the traffic. Without it, the browser shows “Not secure”.
- Slow-client attacks. An attacker opens thousands of connections and sends one byte a minute (this attack is called “Slowloris”). The server keeps every connection open and can run out of room for real users. Nginx is built to handle exactly this.
- Nothing between the world and your code. There is no rate limit (a cap on how many requests one visitor can make), no size cap on uploads, and no safety headers.
- Ugly URLs. Nobody wants to type
:3000.
The six jobs Nginx does for you
| Job | What it means | Benefit |
|---|---|---|
| 1. Terminates HTTPS | Does the encryption work (HTTPS, using a certificate). “Terminate” means Nginx ends the encrypted connection, so your app only speaks plain HTTP on its own machine (localhost). | Lesson 4 — done once, in one place |
| 2. Forwards requests | Passes each request to the right app on a private port. For example, / goes to Next.js and /api-v2 goes to another service. | One public door, many apps behind it |
| 3. Compresses | Gzip is a way to shrink text files. Nginx gzips HTML, CSS, JS and JSON before it sends them. Text often becomes 60–80% smaller. | Pages load faster, bandwidth costs less |
| 4. Caches static files | Keeps copies of files that never change (/_next/static) and serves them itself, so Node is not bothered. | Node does app logic, not file serving |
| 5. Protects | Limits how fast one visitor can send requests (rate limiting), caps upload sizes, adds security headers, and hides which software you use. | A shield in front of a process that can break |
| 6. Survives your app | Keeps answering with a clean error page when Node restarts or crashes. | Deploys and crashes look clean to users |
Partly. An ALB (Application Load Balancer, Lesson 12) is an AWS service that shares requests across many servers. It also ends HTTPS, so many teams skip Nginx once they have an ALB. Nginx is still useful for compression, caching static files, rate limiting, and routing by hostname on a single server. It is also useful when you have no ALB yet. The same ideas appear in ALB rules, Kubernetes ingress and API gateways, so it is worth learning them once.
Install and first look
On the server, install the nginx package. It starts at once as a systemd service (systemd is the Linux tool that starts and stops background programs). It listens on port 80 and shows a “Welcome to nginx!” page.
For this to work from your laptop, the Security Group (the AWS firewall for a server) must allow ports 80 and 443. In Lesson 6 we allowed them only from the future load balancer. Until Lesson 12, open them to the whole internet on the app's group.
In the console, add inbound rules for HTTP 80 and HTTPS 443 from 0.0.0.0/0 to web-sg, then browse to http://SERVER_IP — you should see the welcome page.
How an Nginx config is organised
Configuration is text files in /etc/nginx/. The layout on Ubuntu:
/etc/nginx/
├── nginx.conf # the main file: workers, logging, gzip defaults; includes the folders below
├── sites-available/ # one file per website — all of them, enabled or not
│ ├── default
│ └── myapp # ← the file you will create
└── sites-enabled/ # symlinks (shortcuts) to the files in sites-available that are LIVE
└── myapp -> ../sites-available/myapp
/var/log/nginx/
├── access.log # one line per request
└── error.log # what went wrongThink of the config as boxes inside boxes. A directive is one setting, such as listen 80;. A block is a pair of curly braces that groups settings. There are three levels:
http { # everything about web traffic
server { # one website (matched by port + server_name)
listen 80;
server_name yourapp.com;
location / { # one URL path inside that site
proxy_pass http://127.0.0.1:3000;
}
}
}Enabled vs available is a safe habit. You write the site in sites-available and test it. Then you enable it with a symlink (a small shortcut file that points to another file). To switch the site off, remove the symlink. Your work is not deleted.
The reverse-proxy config, line by line
Create /etc/nginx/sites-available/myapp. This is the core of a production-ready file. We add more to it later in the lesson.
# Lets WebSocket connections pass through (a WebSocket is a long-lived two-way connection: chat, live updates)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80; # same, for IPv6 (the newer kind of IP address)
server_name yourapp.com www.yourapp.com;
location / {
proxy_pass http://127.0.0.1:3000; # forward to the app on the private port
proxy_http_version 1.1; # needed for WebSockets
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;
proxy_read_timeout 60s; # give up waiting for the app after 60 s
}
}sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/Turn the site on. Also delete sites-enabled/default, so the welcome page stops answering
sudo nginx -tALWAYS test the config before you reload. It prints “syntax is ok” and “test is successful”
sudo systemctl reload nginxApply the change without cutting any open connection (a restart would cut them)
What each line does
| Directive | Meaning |
|---|---|
listen 80 | Accept plain HTTP connections on port 80. |
server_name | Which hostnames this site answers for. One server can host many sites. Nginx picks the site by the Host header, a line in the request that says which domain the user typed. |
location / | Applies to every path that starts with /. That means all paths, unless a more specific location exists. |
proxy_pass http://127.0.0.1:3000 | The one line that makes it a reverse proxy. Use 127.0.0.1, not localhost. The name localhost can point to the IPv6 address ::1, and your app may not listen there. |
proxy_http_version 1.1 | Older Nginx versions talk to the app with HTTP 1.0 by default. HTTP 1.0 cannot upgrade to WebSockets or keep connections open. Set 1.1. |
proxy_read_timeout | How long Nginx waits for the app to send data before it gives up and returns 504 (default 60 s). |
Next.js can send a page in pieces (streaming). By default Nginx collects the whole answer before it sends it on, which breaks streaming. If you use streaming, have the app send the header X-Accel-Buffering: no, or add proxy_buffering off; to the location block.
Why the forwarded headers matter
A reverse proxy has a side effect: your app no longer talks to the user. It talks to Nginx. A header is a named line of extra information that travels with a request. Without help, the app thinks every request comes from 127.0.0.1, over plain HTTP, for localhost:3000. That breaks real features. So Nginx adds headers that tell the app the truth:
| Header Nginx adds | Tells the app | What breaks without it |
|---|---|---|
Host | The domain the user typed | Finding the customer (tenant) from the subdomain, full URLs, cookies for the wrong domain |
X-Forwarded-For / X-Real-IP | The user's real IP | Every log line and every rate limiter sees 127.0.0.1 |
X-Forwarded-Proto | Whether the user used http or https | Redirect loops, cookies not marked Secure, http:// links in emails |
Upgrade / Connection | “Switch this connection to WebSocket” | Live features fail without any error |
Any client can send a fake X-Forwarded-For header. This is safe here only because the app can be reached only through Nginx (port 3000 is closed; see the next section). If the app were public, an attacker could fake their IP and get past every check. This is one more reason to keep the port private.
Compression, caching and upload limits
Add these to the same server block. First compression and the upload cap:
client_max_body_size 10m; # reject uploads bigger than 10 MB with 413
gzip on;
gzip_comp_level 5; # 1 = fast but bigger files … 9 = slow but smaller files. 4–6 is a good balance
gzip_min_length 1024; # tiny responses are not worth compressing
gzip_proxied any; # also compress responses that came from the app behind Nginx
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml image/svg+xml; # text formats only. Images and PDFs are already compressedNext.js can also gzip by itself. Doing it in Nginx frees Node's CPU for real work. If you turn on Nginx compression, set compress: false in next.config.ts so the work is not done twice.
Cache the immutable static files
Next.js puts its build files under /_next/static/. Each file name contains a hash, a short code made from the file's content. When the content changes, the name changes. So a file with a given name never changes, and it is safe to cache for a whole year. Let Nginx keep a copy and answer from it, so Node is not bothered.
This needs two pieces of config: a small cache defined at the top of the file, and a location /_next/static/ block that uses it. Next.js already sends the header Cache-Control: public, max-age=31536000, immutable for these files, and Nginx passes it on to browsers. (This header tells the browser: keep this file for a year, it will never change.)
proxy_cache_path /var/cache/nginx/next levels=1:2 keys_zone=next_static:10m
max_size=1g inactive=7d; # top of the file, outside server { }
location /_next/static/ { # inside server { }
proxy_cache next_static; # use the cache defined above
proxy_cache_valid 200 365d; # keep good answers for a year
proxy_pass http://127.0.0.1:3000;
}Lesson 11 takes this idea further. It moves these files to a CDN (Content Delivery Network: servers around the world that keep copies of your files close to users).
Protecting the app: rate limits and security headers
Rate limiting
Rate limiting means capping how many requests one visitor can make in a given time. Without it, a script can call a login form or a search endpoint ten thousand times a second. That can crash your database or let attackers guess passwords. Nginx can stop the extra requests before Node ever sees them:
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s; # top of the file
location /api/ { # inside server { }
limit_req zone=perip burst=20 nodelay;
limit_req_status 429; # "Too Many Requests"
proxy_pass http://127.0.0.1:3000;
}The zone perip keeps one counter for each client IP address (it uses 10 MB of memory). rate is the steady speed that is allowed. burst is how many extra requests may wait in line for a moment. nodelay serves those waiting requests at once instead of spacing them out. For a login endpoint, use a much stricter rate, for example rate=5r/m (5 per minute).
Security headers
A security header is an instruction that tells the browser to be stricter. Add these three and hide the Nginx version number:
add_header X-Content-Type-Options "nosniff" always; # stop browsers from guessing file types
add_header X-Frame-Options "SAMEORIGIN" always; # stop other sites from showing your page in a hidden frame (clickjacking)
add_header Referrer-Policy "strict-origin-when-cross-origin" always; # share less of your URL with other sites
server_tokens off; # hide the Nginx version numberadd_headerin alocationblock replaces (it does not add to) the headers set in the outerserverblock. If a location uses anyadd_header, repeat the security headers there.- Add
Strict-Transport-Security(HSTS) only after HTTPS works everywhere. HSTS is a header that tells browsers “always use HTTPS for this site” for the time you set (often a year). If HTTPS breaks, visitors are locked out.
HTTPS with Certbot
Lesson 4 explained certificates. A certificate is a file that proves your server really owns the domain. Let's Encrypt is a free service that issues certificates. Certbot is a tool that gets a certificate for you and sets up Nginx to use it. Before you start, you need two things. First, a DNS A record from Lesson 3. DNS is the internet's phone book, and an A record is one entry in it that points a domain name at an IP address. Here it points your domain at the server's Elastic IP (an Elastic IP is a fixed public IP address that AWS lets you keep). Second, port 80 open, because Let's Encrypt checks that you own the domain by making an HTTP request to it.
sudo certbot --nginx -d yourapp.com -d www.yourapp.comProves you own the domain, downloads the certificate, and edits your config. It adds a 443 server block and an HTTP to HTTPS redirect. Install certbot and python3-certbot-nginx first
A Let's Encrypt certificate lasts 90 days. A systemd timer renews it automatically when 30 days are left. This stops the 3 a.m. “certificate expired” outage. Run sudo certbot renew --dry-run once to be sure renewal works.
Open your config afterwards and read what Certbot changed. Always review generated configuration:
Certbot added a listen 443 ssl block with the two certificate paths. It also changed the port-80 block into a small redirect to https. This is the same shape as in Lesson 4.
Test it. curl -I http://yourapp.com should return 301 (a “moved permanently” answer) that points to https. curl -I https://yourapp.com should return 200. Then check your domain with ssllabs.com/ssltest. The Certbot defaults usually score an A.
Search engines treat www.yourapp.com and yourapp.com as two sites. Pick one as the official address. For the other name, add a small server block that contains return 301 https://yourapp.com$request_uri;.
Nginx behind a load balancer
In Lesson 12 an ALB will sit in front of your servers and end HTTPS itself. It uses a free ACM certificate (ACM is AWS Certificate Manager, a service that issues and renews certificates). The VPC (Virtual Private Cloud) is your own private network inside AWS. Then Nginx receives plain HTTP from the ALB, and two things change:
- No Certbot on the servers. Nginx listens only on port 80, and the certificate lives on the ALB.
- The real client IP and protocol arrive in headers from the ALB, not from the connection itself. So Nginx must believe those headers when they come from your VPC. It must also pass the ALB's protocol header on instead of replacing it.
That gives three small changes, all in the server block:
- Trust the ALB's headers only from inside the VPC (
set_real_ip_from 10.0.0.0/16plusreal_ip_header X-Forwarded-For). Then logs and rate limits see the real visitor's IP. (10.0.0.0/16is an example. Use your own VPC range.) - Forward the ALB's
X-Forwarded-Protoinstead of Nginx's own$scheme. Behind an ALB,$schemeis always “http”. - A cheap health endpoint (
location = /nginx-healththat returns 200). A health check is a small request that a load balancer sends again and again to see if a server is alive. This one is answered by Nginx itself, so it does not wake the app.
set_real_ip_from 10.0.0.0/16; # the ALB is inside your VPC range
real_ip_header X-Forwarded-For; # read the visitor IP from this header
real_ip_recursive on;
location = /nginx-health { # cheap check for the ALB
access_log off;
return 200 "ok";
}
# in location / { }, replace the old line with:
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; # the ALB's valueIf you get this wrong, you will see a classic problem: a redirect loop. The app thinks the request was HTTP (because Nginx said so), so it redirects to HTTPS. The ALB sends the request back, and the browser gives up.
Note: the Lesson 12 build publishes the container straight on port 80 and does not use Nginx. This section is for the case where you keep Nginx on each server.
Multi-tenant subdomains
A tenant is one customer of your app. Lesson 3 set up a wildcard DNS record *.yourapp.com, so every customer gets a subdomain with no extra DNS work. Nginx makes the same idea work on the server. One server block answers for all subdomains, and the app finds the tenant from the Host header that we already pass on.
server_name yourapp.com *.yourapp.com; # wildcard match
proxy_set_header Host $host; # the app reads "acme.yourapp.com" → tenant "acme"You cannot prove ownership of *.yourapp.com with an HTTP request. Let's Encrypt needs a DNS challenge: you prove ownership by adding a TXT record to your DNS (a TXT record is a DNS entry that holds plain text). With Route 53 this is automatic: sudo certbot certonly --dns-route53 -d yourapp.com -d "*.yourapp.com". It needs the python3-certbot-dns-route53 plugin and an IAM role that may edit the hosted zone. Or skip all of this. Put the wildcard certificate on the ALB with ACM, which handles wildcards and renewals for free (Lesson 12).
Close the door on 3000
Now that Nginx is the front door, make sure it is the only door. Check two places:
- Delete the temporary port-3000 rule from Lesson 7 in
web-sg. - On the server, run
sudo ss -tlnp | grep 3000. The address must be127.0.0.1:3000(with Docker, use-p 127.0.0.1:3000:3000). It must never be0.0.0.0:3000, which means “open to everyone”. - From your laptop,
http://SERVER_IP:3000must time out or be refused, whilehttps://yourapp.comstill works.
Operating Nginx: test, reload, logs
| Task | Command |
|---|---|
| Test config before applying | sudo nginx -t |
| Apply config without cutting open connections | sudo systemctl reload nginx |
| Watch requests live | sudo tail -f /var/log/nginx/access.log |
| See errors | sudo tail -f /var/log/nginx/error.log |
A line in the access log reads like this, left to right:
203.0.113.7 - - [15/Jan/2026:09:30:12 +0000] "GET /dashboard HTTP/2.0" 200 5123 "https://yourapp.com/" "Mozilla/5.0 ..."
# client IP timestamp method path protocol status bytes referrer browserRun nginx -t before every reload. If the new config has a typo, a reload is refused and Nginx keeps the old config. But a restart with a typo can leave Nginx stopped, and then every site on the server is down. The -t check finds the typo first, while the old config keeps serving.
502, 504, 413 and other errors decoded
| You see | It means | Do this |
|---|---|---|
502 Bad Gateway | Nginx is fine, but it could not talk to the app. The app is down, crashed, still starting, or on a different port. | docker ps / pm2 status; curl -I http://127.0.0.1:3000; check the port in proxy_pass; sudo tail /var/log/nginx/error.log (“connect() failed … Connection refused”) |
504 Gateway Timeout | The app accepted the request but took longer than proxy_read_timeout (default 60 s) to answer. | Find the slow endpoint and fix it. If the work is really long, raise the timeout for that location only. |
413 Request Entity Too Large | Upload bigger than client_max_body_size (default 1 MB) | Raise it to what you need (10m, 50m). Better still, upload big files straight to S3 (Lesson 11). |
Too many redirects (redirect loop) | The app and Nginx (or the ALB) both redirect HTTP to HTTPS, and the app does not see X-Forwarded-Proto: https. | Pass X-Forwarded-Proto, make the app trust it, and redirect in only one place. |
Default “Welcome to nginx!” page | Your site file is not enabled, or the default site is answering first. | Make the symlink in sites-enabled, remove the default site, run nginx -t, then reload. |
nginx: [emerg] bind() to 0.0.0.0:80 failed (Address already in use) | Another program (Apache, a Docker container) already uses port 80. | Run sudo ss -tlnp | grep :80 to find it, then stop that service. |
Certificate not renewing | The certbot timer is not running, or port 80 is blocked so the ownership check fails. | Run sudo certbot renew --dry-run. Check that the Security Group allows port 80. |
A 5xx code means the server side had an error. 502 = the app is not there (or it refused the connection). 503 = the service is overloaded or switched off on purpose. 504 = the app is there but too slow. If the browser shows Nginx's error page, Nginx is healthy and the problem is behind it. Always read error.log first.
Interview corner
What is a reverse proxy and why use one?
It is a server that stands in front of your application servers. It receives client requests and passes them on. It does HTTPS, compression, caching, rate limiting and routing in one place. It hides your internal setup, and it protects the app from slow or harmful clients.
Forward proxy vs reverse proxy?
A forward proxy works for the clients (a company proxy, a VPN). It hides the client from servers. A reverse proxy works for the servers. It hides the servers from clients.
Users get 502 right after a deploy. Where do you look?
I read the Nginx error log and look for “connection refused”. Then I check that the app process or container is running on the expected port. Often the app crashed at start (a bad env variable or a failed migration) or is still starting. I would add a health check and a zero-downtime reload.
Why does the app need X-Forwarded-Proto?
TLS (the encryption layer) ends at the proxy, so the app sees plain HTTP. The header tells the app the original protocol. The app needs this for redirects, secure cookies and links it builds. Without it, you get redirect loops.
reload vs restart in Nginx?
Reload starts new worker processes with the new config. The old workers finish the requests they are already handling, so no connection is cut. If the new config is broken, the reload fails and the old config keeps running. Restart stops everything and starts again. Always run nginx -t first.
How would you rate-limit a login endpoint?
I define a limit_req_zone keyed on the client IP, with a strict rate (a few per minute). I add limit_req with a small burst on that location and return 429. I also make sure Nginx can see the real client IP (the real_ip module behind an ALB). Otherwise every user shares one counter.
Nginx vs ALB — which do you pick?
I pick an ALB when I need many servers, health checks, managed certificates and autoscaling. I pick Nginx for a single host, for detailed caching or header rules, or as a helper next to the app. Many production systems use both: the ALB outside and Nginx on each server.
Practice task before Lesson 11
- Install Nginx, write the
myappsite, enable it, and reach your app on plain port 80 with no port number. - Point a real (or free) domain at the Elastic IP, run Certbot, and confirm the padlock and the HTTP→HTTPS redirect.
- Prove port 3000 is closed from the internet with the checks in the lesson.
- Stop the app and load the site. You should see a
502. Find the matching line inerror.log. Start the app again. - Add rate limiting to an endpoint and refresh it quickly many times (or loop it with
curl). Watch the 200s turn into 429s. - Make a deliberate typo in the config and run
sudo nginx -t. Read the error, fix it, and only then reload.
Add the wildcard server_name *.yourapp.com block and make your app print the subdomain from the Host header. Test it by sending a fake Host: acme.yourapp.com header with curl before touching DNS.
Conclusion
Nginx is the receptionist that lets your app stay private and fast. Every public request meets it first.
- Reverse proxy: public 80/443 to private 3000. Port 3000 is never open to the internet, neither in the Security Group nor in Docker's publish setting.
- Pass on the facts:
Host,X-Forwarded-ForandX-Forwarded-Proto, so the app still knows who, where and how. - Do the cheap work at the front door: gzip, caching of static files, upload limits, rate limits, security headers.
- HTTPS in one command with Certbot, plus a renewal timer that you have checked.
- Test before you reload (
nginx -t), and readerror.logbefore guessing.
The app is now reachable, secure and quick. Next, we move the heavy files (images, uploads and built assets) off the server completely, to S3 and CloudFront.
End of Lesson 10. Next: Lesson 11 — S3 and CloudFront: Static Assets and CDN.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series