Why Your Full-Stack App Breaks in Production (and How Nginx Fixes It)
You've built a full-stack application. On your local machine, everything runs like a charm. React or Next.js is running happily on port 3000, your Node or Express backend is cruising on port 5000, and all your API calls
You've built a full-stack application. On your local machine, everything runs like a charm. React or Next.js is running happily on port 3000, your Node or Express backend is cruising on port 5000, and all your API calls resolve in milliseconds.
Then you decide to deploy it to a live VPS.
Almost immediately, things fall apart:
The dreaded CORS error: Your browser suddenly refuses to make cross-origin requests.
Mixed content warnings: Your frontend is loaded over HTTPS, but your backend API calls fail because they're still plain HTTP.
Exposed internal ports: You realize port 5000 is wide open to the public internet, and anyone can ping your backend directly.
Don't worry, you're not alone. Almost every developer hits this wall when moving from local dev to a real server environment.
The root problem isn't your app codeβit's how traffic enters your server. Hereβs why this happens and how a simple Nginx reverse proxy fixes it for good.
The "Localhost Illusion"
When you build locally, your setup looks something like this:
[ Browser ]
βββ> http://localhost:3000 (Frontend)
βββ> http://localhost:5000/api (Backend API)
Because both services live on your machine, itβs tempting to slap a wildcard CORS header on the backend (Access-Control-Allow-Origin: *) or rely on a dev server proxy, call it a day, and keep coding.
In production, that shortcut creates three headaches:
Security: Exposing port 5000 directly to public traffic leaves your backend service vulnerable to direct port scanning and attacks.
SSL Mess: You have to manage SSL certificates across multiple ports instead of terminating HTTPS in one spot.
Fragile URLs: Hardcoding https://api.yourdomain.com:5000 in your frontend client code creates preflight OPTIONS overhead on every request and looks messy.
What a Reverse Proxy Actually Does
Instead of letting external users talk directly to your individual services, you put a gatekeeper in front: a reverse proxy.
Nginx sits on standard public web ports (80 for HTTP, 443 for HTTPS). When a request hits your domain, Nginx looks at the URL path and routes it internally behind closed doors:
βββββββββββββββββββββββββββββββ
β Nginx (Port 80/443) β
β β
[ Client Request ] ββββ> β / ββ> Frontend (:3000) β
β /api/ ββ> Backend (:5000) β
βββββββββββββββββββββββββββββββ
Why does this immediately fix your headache?
No more CORS: Your frontend and your backend now share the exact same domain origin (yourdomain.com). The browser has no reason to throw cross-origin errors.
Internal services stay hidden: Your backend binds to 127.0.0.1:5000 locally. No random ports open to the outside world.
Clean SSL: Nginx handles HTTPS encryption once at the top level. The internal jump from Nginx to your Node/Python app happens over fast, local HTTP.
The Step-by-Step Nginx Configuration
Here is the exact server block you need. You'll usually drop this into /etc/nginx/sites-available/default (on Linux) or your nginx.conf:
Nginx
server {
listen 80;
server_name yourdomain.com [www.yourdomain.com](https://www.yourdomain.com);
# 1. Route web traffic to the frontend
location / {
proxy_pass [http://127.0.0.1:3000](http://127.0.0.1:3000);
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# 2. Route API calls to the backend
location /api/ {
proxy_pass [http://127.0.0.1:5000/](http://127.0.0.1:5000/);
# Forward the real client information
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;
}
}
Two Gotchas That Will Trip You Up
- The Trailing Slash Trap Look very closely at line 16:
Nginx
proxy_pass [http://127.0.0.1:5000/](http://127.0.0.1:5000/);
That trailing slash at the end of :5000/ matters immensely:
With the slash (/): Nginx strips /api/ before passing it to your backend. So a client request to yourdomain.com/api/users hits your backend as /users.
Without the slash (:5000): Nginx keeps the prefix, so the request reaches your backend as /api/users.
If your server keeps returning 404 Not Found, check this line first.
- Losing the User's Real IP Without these headers:
Nginx
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Every request arriving at your backend will appear to come from 127.0.0.1. If you have rate limiters, auth tracking, or IP loggers in your backend code, they'll treat every single user like the same person. Passing these headers preserves the real visitor's IP address.
Testing and Reloading
Never restart Nginx blindly. Always run a syntax check first:
Bash
sudo nginx -t
If it says syntax is ok and test is successful, reload it cleanly without dropping active connections:
Bash
sudo nginx -s reload
Takeaways
When deploying to a real server:
Don't expose internal app ports (3000, 5000, 8000) to the public internet. Keep them listening on 127.0.0.1.
Let a reverse proxy route traffic by path. It solves CORS, simplifies SSL certificates, and keeps your architecture clean.
Keep an eye on trailing slashes in your proxy_pass rulesβthey are the #1 cause of silent 404s.
How did your very first deployment go? Did you run into CORS, firewalls, or something completely different? Drop a comment belowβI'd love to hear how you solved it!
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.