Here is the short answer. An Nginx reverse proxy is one front door for all your apps. Nginx listens on ports 80 and 443, reads the domain name the visitor asked for, and passes the request to the right app on your server, for example app.mouhimen.dev to 127.0.0.1:3000 and api.mouhimen.dev to 127.0.0.1:8000. Each site is one server block with a proxy_pass line. Test with sudo nginx -t, reload, then add free SSL with sudo certbot --nginx.
Watch the video: https://youtu.be/ZL0d33xVBQc
I checked every command on 13 November 2026 against the official nginx documentation and the Ubuntu Server docs, and I copied the error log lines in this post from a real nginx test. The examples use Ubuntu 26.04 LTS, which ships nginx 1.28.
What is a reverse proxy?
Think of a hotel receptionist. Every guest comes in through the same front door, says which room they want, and the receptionist takes them there. Guests never see the back corridors.
Nginx plays the receptionist:
- A browser connects to your server on port 80 (HTTP) or 443 (HTTPS).
- The request carries a
Hostheader, such asapp.mouhimen.dev. - Nginx matches that name to a
serverblock. - The
proxy_passline sends the request to the app, for example on port 3000. - The app answers Nginx, and Nginx sends the answer back to the browser.
The visitor only ever talks to Nginx.
Forward proxy vs reverse proxy: a forward proxy sits in front of users (like an office proxy that filters which sites staff can open). A reverse proxy sits in front of servers and protects them.
What you get from a reverse proxy:
- many apps on one public IP, with no port numbers in the URL,
- SSL certificates in one place instead of inside every app,
- app ports hidden from the internet,
- one place for access and error logs,
- and later, load balancing across several servers with the same setup.
If you’re new to Nginx, read my Secure Nginx in 5 settings video ([VIDEO LINK: Secure Nginx in 5 settings]) after this one, and see one DNS zone with 5 subdomains for the DNS side.
Step 1: Install Nginx on Ubuntu
sudo apt update && sudo apt install nginx
sudo systemctl status nginx
Ubuntu installs Nginx and starts it. The status should say active (running).
If you use UFW, allow the profile that the nginx package adds:
sudo ufw allow 'Nginx Full' # opens 80/tcp and 443/tcp
Open http://YOUR_SERVER_IP in a browser and you’ll see Welcome to nginx!
Step 2: Point DNS at the server
At your domain provider, add two A records that point to the same server IP:
| Type | Name | Points to |
|---|---|---|
| A | app | 203.0.113.10 |
| A | api | 203.0.113.10 |
One IP, two names. (203.0.113.10 is a documentation address; use your server’s real IP.)
Before you touch Nginx, check that both apps really run on the server:
curl -s localhost:3000 | head -c 60 # the Next.js site
curl -s localhost:8000/api/health # the Laravel API
My rule: never blame Nginx until this test works.
Step 3: Create a server block for each app
Ubuntu keeps site files in /etc/nginx/sites-available/. Create /etc/nginx/sites-available/app.mouhimen.dev:
server {
listen 80;
server_name app.mouhimen.dev;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
What the header lines do:
Host $hosttells the app which domain was asked for. Without it, nginx sends its own value ($proxy_host) instead.X-Real-IPandX-Forwarded-Forgive the app the visitor’s real IP, not 127.0.0.1.X-Forwarded-Proto $schemetells the app whether the visitor used HTTP or HTTPS. Keep it: it fixes error 4 below.
Copy the file to api.mouhimen.dev, change server_name to api.mouhimen.dev and the port to 8000.
Enable both sites, remove the default site, test and reload:
sudo ln -s /etc/nginx/sites-available/app.mouhimen.dev /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/api.mouhimen.dev /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
nginx -t should print syntax is ok and test is successful. Run it before every reload: it has saved me from taking a client’s sites down more than once.
WebSockets (live chat, dashboards)
nginx 1.28 (Ubuntu 26.04) still talks HTTP/1.0 to your app by default; HTTP/1.1 became the default only in nginx 1.29.7. For WebSockets, add these lines inside location /:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Step 4: Free SSL with Certbot
You need DNS already pointing at the server and port 80 open, because Let’s Encrypt checks your domain over HTTP. Ubuntu’s docs install Certbot as a snap:
sudo snap install --classic certbot
sudo certbot --nginx -d app.mouhimen.dev -d api.mouhimen.dev
Certbot edits your server blocks for you. It adds listen 443 ssl, the certificate paths in /etc/letsencrypt/live/, and a return 301 https://$host$request_uri; redirect from HTTP to HTTPS. Refresh the browser and both sites show the padlock.
Renewal is automatic: a systemd timer tries twice a day, and Nginx is reloaded after a renewal. Prove it works:
sudo certbot renew --dry-run
You should see Congratulations, all simulated renewals succeeded.
Errors and fixes
Every one of these is explained in one file. Start here:
sudo tail -f /var/log/nginx/error.log
1. 502 Bad Gateway
connect() failed (111: Connection refused) while connecting to upstream,
server: app.mouhimen.dev, upstream: "http://127.0.0.1:3000/"
Nginx is fine. Your app isn’t answering on that port. Check what is listening, start the app (systemd, pm2, Supervisor), and make sure the port matches proxy_pass:
sudo ss -tlnp | grep 3000
2. 504 Gateway Timeout
upstream timed out (110: Connection timed out) while reading response header from upstream
The app took longer than 60 seconds, the default proxy_read_timeout. Raise it only for the slow route, not the whole site:
location /reports/ {
proxy_read_timeout 300s;
proxy_pass http://127.0.0.1:8000;
}
Better still, move the slow job (exports, reports) to a queue, such as Laravel queues.
3. 413 Request Entity Too Large
client intended to send too large body: 2000000 bytes
Nginx accepts request bodies up to 1 MB by default. For uploads, add this to the server block and reload:
client_max_body_size 20M;
Your app (for example PHP’s upload_max_filesize) may have its own limit too.
4. ERR_TOO_MANY_REDIRECTS after adding SSL
This one looks like an Nginx bug, but it isn’t. Nginx talks to your app over plain HTTP. If the app forces HTTPS, it thinks every visitor is insecure, redirects to HTTPS, and the loop never ends.
The fix has two parts:
- Keep
proxy_set_header X-Forwarded-Proto $scheme;in Nginx. - Tell the app to trust the proxy. In Laravel 11 and newer, in
bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '127.0.0.1');
})
Other frameworks have the same idea (for example app.set('trust proxy', 1) in Express).
Lock the back door
Even with a perfect proxy, try http://YOUR_SERVER_IP:3000 from another network. On many servers the app opens directly, skipping Nginx, SSL and your logs.
Make each app listen on localhost only, for example next start -H 127.0.0.1 -p 3000 or php artisan octane:start --host=127.0.0.1 --port=8000, and let the firewall allow just SSH and Nginx:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
If your apps run in Docker, publish their ports as 127.0.0.1:3000:3000. Docker can open published ports past UFW.
FAQ
What is the difference between a reverse proxy and a load balancer?
A reverse proxy forwards requests to one or more backend apps. A load balancer is a reverse proxy that spreads requests across several copies of the same app. Nginx does both with an upstream block.
Do I need a reverse proxy for one app?
It still helps: free SSL in one place, the app port stays private, and you can add a second app later without changing anything public.
Why do I get 502 Bad Gateway with Nginx?
Almost always the app behind Nginx isn’t running, crashed, or listens on a different port or address than proxy_pass. Read /var/log/nginx/error.log first.
Should I use localhost or 127.0.0.1 in proxy_pass?
Use 127.0.0.1. localhost can resolve to the IPv6 address ::1, and if your app only listens on IPv4 you get confusing 502 errors.
Is Nginx Proxy Manager the same thing?
Yes, with a web dashboard on top. It’s the next video in this Linux series, for Docker apps.
Related guides
- One DNS zone, 5 subdomains with Nginx server blocks
- Lock down an exposed Ollama server on port 11434
- Laravel API tutorial for beginners
Written by Abd Almouhimen Shamout at mouhimen.dev. Have a topic you want covered next? Leave a comment on the video.
Watch the full video: https://youtu.be/ZL0d33xVBQc
Sources
- nginx documentation, ngx_http_proxy_module (proxy_pass, proxy_set_header, proxy_http_version, proxy_read_timeout): https://nginx.org/en/docs/http/ngx_http_proxy_module.html
- nginx documentation, WebSocket proxying: https://nginx.org/en/docs/http/websocket.html
- nginx documentation, ngx_http_core_module (client_max_body_size): https://nginx.org/en/docs/http/ngx_http_core_module.html
- Ubuntu Server docs, How to install nginx: https://documentation.ubuntu.com/server/how-to/web-services/install-nginx/
- Ubuntu Server docs, How to configure nginx: https://documentation.ubuntu.com/server/how-to/web-services/configure-nginx/
- Ubuntu Server docs, Obtain TLS certificates (Certbot): https://documentation.ubuntu.com/server/how-to/security/obtain-tls-certificates/
- Ubuntu packages, nginx in Ubuntu 26.04 LTS: https://packages.ubuntu.com/resolute/nginx
- Laravel docs, Configuring trusted proxies: https://laravel.com/docs/requests#configuring-trusted-proxies
Leave a Reply