If your Node.js app takes 3 seconds to respond on localhost:3000, or your Python backend dies under load, or a WordPress site is slow because Apache eats all RAM… the problem is rarely the code. It's how you expose that application to the web.
We, at Meteora Web, see projects every day that use Nginx only to serve static files but don't tap into its real power: acting as a reverse proxy. A reverse proxy is not a simple pass-through. It distributes load, protects the backend, terminates SSL, manages caching, and – if configured properly – can make the difference between a site that handles 10 visitors and one that handles 10,000 without crashing.
In this guide, we show how to configure Nginx as a reverse proxy for three of the most common stacks: Node.js, PHP-FPM, and Python. No abstract theory. Only commands, configuration files, and concrete decisions to make.
Why a Reverse Proxy? Why Not Direct Server Access?
You could expose Node.js on port 3000, Python on 5000, and PHP on an Apache server. But then:
- Each application manages SSL independently (double work, potential errors)
- No centralized caching for static files, images, CSS
- No load balancing if you have multiple instances of the same service
- You expose the backend directly to the internet: increased attack surface
With Nginx as a reverse proxy, a single entry point (port 443) routes traffic to the correct backend, handles SSL, compresses, caches, rate limits, and filters malicious requests. One server acting as a guard.
We use it for all our projects – from our proprietary Laravel platform to client WordPress sites. It's stable, lightweight, and consumes fewer resources than Apache. No wonder it's the most used web server in the world.
Basic Configuration: The Server Block for a Proxy
Every reverse proxy in Nginx is configured with a server block and a location containing proxy_pass. Here's the minimum structure we'll use as a base:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header 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;
proxy_cache_bypass $http_upgrade;
}
}Line by line analysis:
proxy_passtells Nginx where to forward requests.proxy_http_version 1.1required for keep-alive connections with the backend.UpgradeandConnectionheaders are crucial for WebSocket (Node.js).Host,X-Real-IP,X-Forwarded-For,X-Forwarded-Protopass real client information to the backend. Without them, the backend always sees localhost.proxy_cache_bypassavoids serving stale cache for upgraded connections.- Common mistake: if the backend expects the real client IP (for logging or rate limiting), skipping X-Forwarded-* headers produces false data. We see it often in audits: backends that see only 127.0.0.1.
What To Do Now
Take a test domain, create a file /etc/nginx/sites-available/test-proxy with the block above. Replace proxy_pass with the address of a listening application (e.g., a simple Python HTTP server). Reload Nginx: sudo nginx -t && sudo systemctl reload nginx. Verify that requests reach the backend and that headers are correct (use curl -I or a tool like nghttp).
Reverse Proxy for Node.js (Express, Fastify, Next.js)
Node.js is single-threaded and handles async requests. Nginx helps with:
- Static file serving: Nginx serves CSS, JS, images directly without hitting Node.
- Compression: gzip or brotli handled by Nginx, less load on Node.
- WebSocket: Nginx supports WebSocket proxying if configured as above.
- Rate limiting: you limit at the proxy level, not the application level.
Typical Configuration for a Node.js App
server {
listen 80;
server_name app.example.com;
# Static files served directly
location /static/ {
root /var/www/app/public;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Everything else goes to Node
location / {
proxy_pass 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_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_cache_bypass $http_upgrade;
# Specific timeouts for Node
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Separate logs
access_log /var/log/nginx/node-access.log;
error_log /var/log/nginx/node-error.log;
}Common Node.js mistakes:
- Default buffering: Nginx buffers the backend response before sending to client. For streams or large responses (e.g., downloads), it can cause delays. Disable with
proxy_buffering offinside the location. - Timeouts too low: if a Node endpoint takes more than 60 seconds (e.g., report generation), increase
proxy_read_timeoutto 300s. - WebSocket without Upgrade: if you forget the
UpgradeandConnectionheaders, WebSocket connections drop after a few seconds.
Practical Example: Node + WebSocket
If you use Socket.IO, add a specific location for the WebSocket namespace:
location /socket.io/ {
proxy_pass 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_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_read_timeout 86400s; # 24 hours for persistent WebSockets
}The long read timeout prevents Nginx from closing the WebSocket connection after 60 seconds.
Reverse Proxy for PHP-FPM (WordPress, Laravel, Symfony)
PHP-FPM (FastCGI Process Manager) does not speak HTTP directly; it uses the FastCGI protocol. Nginx handles it with fastcgi_pass instead of proxy_pass. The difference is substantial: you must pass FastCGI-specific variables using fastcgi_param.
Base Configuration for Laravel/WordPress
server {
listen 80;
server_name blog.example.com;
root /var/www/blog/public; # The public folder of the project
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; # or 127.0.0.1:9000
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Appropriate timeouts
fastcgi_read_timeout 300;
fastcgi_connect_timeout 60;
fastcgi_send_timeout 60;
# Optimized buffers
fastcgi_buffers 16 32k;
fastcgi_buffer_size 32k;
fastcgi_busy_buffers_size 64k;
}
# Deny access to sensitive files
location ~ /\.ht {
deny all;
}
}Critical points:
try_files $uri $uri/ /index.php?$query_stringis the core of routing for PHP frameworks. Without it, Nginx won't pass requests to index.php for URLs like/contact.SCRIPT_FILENAMEmust point to the correct PHP file. Common mistake: using$fastcgi_script_namewithout$document_root.- Unix socket (
unix:/var/run/php/php8.2-fpm.sock) is faster and more secure than TCP, but ensure permissions allow Nginx to write. - WordPress: sometimes requires an additional rule for uploading multipart files:
client_max_body_size 64M;in the server block.
PHP-FPM Optimization
If your PHP site gets high traffic, tune FastCGI buffers. Values too low cause disk writes (slow). We use these as a starting point:
fastcgi_buffers 256 4k;
fastcgi_buffer_size 128k;
fastcgi_busy_buffers_size 256k;
fastcgi_temp_file_write_size 256k;Also, enable FastCGI page caching for WordPress sites with little dynamic content:
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=wordpress:10m inactive=60m;
server {
...
location ~ \.php$ {
fastcgi_cache wordpress;
fastcgi_cache_valid 200 60m;
fastcgi_cache_use_stale error timeout invalid_header updating http_500;
...
}
}Caution: FastCGI caching can cause issues with session cookies and WooCommerce. Use it only for public pages or with bypass rules.
Reverse Proxy for Python (Gunicorn, uWSGI, Daphne)
Python has two common protocols: WSGI (Gunicorn, uWSGI) and ASGI (Daphne, Uvicorn). Nginx supports both: for WSGI use proxy_pass (HTTP), for uWSGI use uwsgi_pass. For ASGI, you must use proxy_pass with WebSocket support.
Configuration for Gunicorn (WSGI – Django/Flask/FastAPI)
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
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;
proxy_redirect off;
# Increase timeout for long requests (e.g., machine learning)
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Static files (collected via collectstatic)
location /static/ {
alias /var/www/api/static/;
expires 30d;
}
}Note on Gunicorn: Run Gunicorn with an appropriate number of workers (2-4 per CPU). If using Unix socket, proxy_pass becomes proxy_pass http://unix:/tmp/gunicorn.sock.
Configuration for uWSGI (higher performance)
server {
listen 80;
server_name app.example.com;
location / {
include uwsgi_params;
uwsgi_pass unix:/var/run/uwsgi/app.sock;
uwsgi_read_timeout 120s;
uwsgi_send_timeout 120s;
uwsgi_buffer_size 32k;
uwsgi_buffers 8 32k;
}
location /static/ {
alias /var/www/app/static/;
}
}The uwsgi_params file is usually at /etc/nginx/uwsgi_params and contains variables similar to FastCGI.
Configuration for ASGI (Daphne/Uvicorn) with WebSocket
If you use Django Channels or FastAPI with WebSocket, the proxy must support both HTTP and WebSocket. The configuration is identical to Node.js WebSocket:
location /ws/ {
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header 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;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}Proxy Security: Rules Not To Forget
Exposing a reverse proxy without protection is like leaving your front door open. We, at Meteora Web, apply at least these measures on every project:
- Restrict backend access to localhost: if the backend listens on 127.0.0.1, it's unreachable from outside. Configure Gunicorn with
--bind 127.0.0.1:8000. - Rate limiting:
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;and apply withlimit_req zone=api burst=20 nodelay;on locations. - Hide Nginx version:
server_tokens off;in the http block. - Block unnecessary HTTP methods:
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE)$ ) { return 405; } - Disable proxy_buffering if not needed: avoids buffer overflow on slow backends.
- Use HTTPS: terminate SSL on Nginx with a LetsEncrypt certificate. Never proxy over HTTP if the backend is not local.
In Summary – What To Do Now
- Choose your stack identify which backend you are exposing (Node, PHP-FPM, Python).
- Create a test server file in
/etc/nginx/sites-available/with the correct template. - Test the configuration with
sudo nginx -tand reload. - Verify headers with
curl -I http://localhostand check that X-Real-IP is your public IP (or the test client's IP). - Add security: rate limiting, server_tokens off, bind to localhost.
- Enable HTTPS with Certbot (LetsEncrypt).
If you need help with complex configurations (load balancing multiple instances, advanced caching, multiple WebSockets), we're here. We work with Nginx every day for clients across Italy – from small e-commerce to SaaS enterprise. A well-configured reverse proxy is the first step to a solid infrastructure.
Sponsored Protocol