Nginx Configuration for Web Apps
You've got a Laravel or Node.js app running fine on your laptop, you push it to a fresh VPS, and now you need a domain, HTTPS, static files, and something that won't fall over when ten people hit it at once. Usually that something is Nginx. The catch is that most people end up copy-pasting a config from a Stack Overflow answer, and it works right up until it doesn't: a 502 at midnight, uploads failing with a 413, or a security header that quietly vanished.
This post walks through the configs I'd actually put on a server: installation and file layout, server blocks for static sites and PHP-FPM, SSL with Let's Encrypt, reverse proxying to Node.js and Python, load balancing, performance tuning, rate limiting, and some security basics. Nginx's event-driven design (a few worker processes each juggling thousands of connections with non-blocking I/O, instead of a process or thread per connection) is why it stays light on memory under load, and it's why it's the usual pick for sitting in front of an app server.
Nginx is good at three jobs: serving static files, reverse proxying to an app server (Node.js, PHP-FPM, Gunicorn), and terminating SSL. If you're deploying a Node.js or Laravel app to a plain Linux box, putting Nginx in front of it is the standard setup. Running Node directly on port 80 or 443 is possible, but you'd be rebuilding a lot of what Nginx gives you for free.
Installation and Core Concepts
Every major Linux distro packages Nginx, but the distro version can lag well behind. On Ubuntu/Debian I'd add the official nginx.org repository so you get the current stable release.
Install on Ubuntu/Debian
Add the official Nginx repository for the latest stable version, then install via apt.
# Install from official Nginx repo (latest stable)
curl -fsSL https://nginx.org/keys/nginx_signing.key | sudo gpg --dearmor \
-o /usr/share/keyrings/nginx-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/ubuntu $(lsb_release -cs) nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list
sudo apt update && sudo apt install nginx -y
# Verify and start
nginx -v
sudo systemctl enable --now nginx
sudo systemctl status nginx
Install on CentOS/RHEL/Amazon Linux
Use the official Nginx RPM repository for rpm-based distros.
# Create repo file
sudo tee /etc/yum.repos.d/nginx.repo <<'EOF'
[nginx-stable]
name=nginx stable repo
baseurl=http://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
EOF
sudo yum install nginx -y
sudo systemctl enable --now nginx
Key File Paths
| File/Directory | Purpose |
|---|---|
/etc/nginx/nginx.conf |
Main configuration file: global settings and includes |
/etc/nginx/conf.d/ |
Drop-in server block configs (*.conf files auto-included) |
/etc/nginx/sites-available/ |
Available virtual hosts (Debian/Ubuntu convention) |
/etc/nginx/sites-enabled/ |
Symlinks to active sites from sites-available |
/var/log/nginx/access.log |
All incoming request logs |
/var/log/nginx/error.log |
Error and diagnostic logs |
/var/www/html/ |
Default document root for static files |
Understanding nginx.conf Structure
The config is a tree of nested contexts. Directives set in an outer context are inherited by inner ones unless you override them there (with one nasty exception for add_header, covered in the SSL section).
Nginx configuration context hierarchy
Server Blocks (Virtual Hosts)
If you're coming from Apache, a server block is your VirtualHost. Each one tells Nginx how to handle requests for a given domain or IP, so one box can host many sites, each with its own block and its own file in conf.d/.
Static Site Server Block
server {
listen 80;
listen [::]:80; # IPv6
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html index.htm;
# Serve static files, 404 for missing assets
location / {
try_files $uri $uri/ =404;
}
# Cache static assets aggressively
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Deny access to hidden files (.git, .env)
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Custom error pages
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
# Logging
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log warn;
}
Validate before you reload. sudo nginx -t parses the full config and reports syntax errors without touching the running process. Then apply changes with sudo nginx -s reload: the old workers finish their in-flight requests while new ones pick up the new config, so nothing gets dropped.
PHP-FPM Server Block (Laravel / WordPress)
server {
listen 80;
server_name myapp.com www.myapp.com;
root /var/www/myapp/public;
index index.php index.html;
# Laravel: route all requests through index.php
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Pass PHP files to PHP-FPM
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Increase timeouts for long-running requests
fastcgi_read_timeout 300;
fastcgi_send_timeout 300;
}
# Block access to sensitive Laravel files
location ~ /\.(env|git|htaccess) {
deny all;
}
# Serve storage files (public disk)
location /storage {
alias /var/www/myapp/storage/app/public;
try_files $uri $uri/ =404;
}
client_max_body_size 64M; # Allow large file uploads
}
The socket path in fastcgi_pass includes the PHP version. Upgrade to PHP 8.3, forget to update it, and every request returns 502 Bad Gateway while error.log shows connect() to unix:/run/php/php8.2-fpm.sock failed (2: No such file or directory). Run ls /run/php/ to see which socket actually exists. Related: if uploads fail with 413 Request Entity Too Large, that's client_max_body_size, and PHP's own upload_max_filesize and post_max_size need raising too.
SSL/TLS Configuration
Browsers flag plain HTTP pages as "Not Secure", and APIs like service workers, geolocation, and camera access only work in secure contexts, so HTTPS isn't something to put off. With Let's Encrypt it's free and renews itself.
Install Certbot
Certbot is the client Let's Encrypt recommends. The snap package tracks the latest version.
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
# Obtain and install certificate (Nginx plugin handles everything)
sudo certbot --nginx -d example.com -d www.example.com
# Test auto-renewal
sudo certbot renew --dry-run
Harden Your SSL Configuration
Certbot's default SSL config is fine to start with. For production, tighten the protocols and ciphers and add security headers.
# Redirect all HTTP → HTTPS
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
# Certificates (set by certbot)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Modern TLS settings (A+ on SSL Labs)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Session resumption (performance)
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;
# OCSP stapling (faster cert validation for clients)
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
root /var/www/example.com/public;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
The Strict-Transport-Security header with preload commits your domain to HTTPS-only in browsers' hardcoded lists. This is permanent and can take months to undo. Only add preload when you're certain you'll keep HTTPS on this domain and every subdomain for good.
add_header doesn't merge. If any location block has its own add_header (say, the Cache-Control rule for static assets), that location inherits none of the server-level headers. Your HSTS and X-Frame-Options silently disappear for those responses. Check with curl -I against a CSS or image URL, not just the homepage. Version warnings: on Nginx 1.25.1 and newer, listen 443 ssl http2 still works but logs a deprecation warning; the new form is listen 443 ssl; plus http2 on;. And since Let's Encrypt has dropped OCSP, the stapling lines will just log a warning with their certificates and can be removed.
Nginx as a Reverse Proxy
In this setup your app never talks to the internet directly. Nginx accepts every connection, handles TLS, serves static files itself, and forwards the rest to whatever backend owns it: a Node.js process, Gunicorn, a PHP-FPM pool, or another internal service. Your app only listens on localhost or a Unix socket.
Nginx as a reverse proxy in front of multiple backends
Proxy all requests to a Node.js application on port 3000:
upstream nodejs_backend {
server 127.0.0.1:3000;
keepalive 32; # Reuse connections to backend
}
server {
listen 443 ssl http2;
server_name api.example.com;
# ... SSL config here ...
location / {
proxy_pass http://nodejs_backend;
proxy_http_version 1.1;
# Required for WebSocket support
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 90s;
proxy_connect_timeout 90s;
proxy_send_timeout 90s;
}
}
Proxy to a Python app served by Gunicorn with a Unix socket:
upstream gunicorn_backend {
server unix:/run/gunicorn/gunicorn.sock fail_timeout=0;
}
server {
listen 443 ssl http2;
server_name app.example.com;
# ... SSL config here ...
location / {
proxy_pass http://gunicorn_backend;
proxy_http_version 1.1;
proxy_set_header Host $http_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;
# Buffer settings for slower backends
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Serve Django/Flask static files directly (bypass app server)
location /static/ {
alias /var/www/myapp/staticfiles/;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
The proxy headers that matter and what each one does:
# Your application needs the real client IP, not Nginx's IP
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Tell backend whether the original request was HTTP or HTTPS
proxy_set_header X-Forwarded-Proto $scheme;
# Pass the original Host header (important for virtual hosting)
proxy_set_header Host $host;
# If backend needs the original port
proxy_set_header X-Forwarded-Port $server_port;
# In Laravel, set trusted proxies in App\Http\Middleware\TrustProxies
# or config/trustedproxies.php to use these headers correctly.
# Without trusted proxies, request()->ip() returns Nginx's IP.
Load Balancing
Once one app instance isn't enough, whether for capacity or so a single crash doesn't take the site down, the upstream block lets Nginx spread traffic across several backends.
Load Balancing Algorithms
| Method | Directive | Best For |
|---|---|---|
| Round Robin | (default, no directive) | Stateless apps where all servers are identical |
| Least Connections | least_conn; |
Requests with varying processing times (API, DB-heavy) |
| IP Hash | ip_hash; |
Session-based apps that need sticky sessions |
| Weighted | server ... weight=N; |
Mixed server capacities (route more traffic to larger nodes) |
upstream app_cluster {
# Least connections: routes to server with fewest active connections
least_conn;
# Three backend nodes
server 10.0.1.10:3000 weight=3; # Primary, receives 3x traffic
server 10.0.1.11:3000 weight=2; # Secondary
server 10.0.1.12:3000 weight=1; # Tertiary (smaller instance)
# Backup server: only used if all primaries are down
server 10.0.1.13:3000 backup;
# Health check params
# max_fails: mark server down after N failures
# fail_timeout: how long to mark it as down, then retry
server 10.0.1.14:3000 max_fails=3 fail_timeout=30s;
# Keep alive connections to backends
keepalive 64;
}
server {
listen 443 ssl http2;
server_name app.example.com;
location / {
proxy_pass http://app_cluster;
proxy_http_version 1.1;
proxy_set_header Connection ""; # Required for keepalive upstream
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;
}
}
Performance Optimization
The defaults are fine for a small site. On a busier server, a few changes in nginx.conf (more connections per worker, gzip, file descriptor caching, buffered logging) make a real difference. Change one thing at a time and watch your metrics rather than pasting the whole block blind.
# Match worker count to CPU cores
worker_processes auto;
# Maximum open files per worker (must be >= worker_connections)
worker_rlimit_nofile 65535;
events {
# Max simultaneous connections per worker
worker_connections 4096;
# Accept all queued connections at once (multi_accept on)
multi_accept on;
# Use Linux's most efficient I/O method
use epoll;
}
http {
# ── Basics ───────────────────────────────────────────
sendfile on; # Kernel-level file transfer (bypasses userspace)
tcp_nopush on; # Send headers in one packet (works with sendfile)
tcp_nodelay on; # Disable Nagle for low-latency
keepalive_timeout 65;
keepalive_requests 1000;
types_hash_max_size 2048;
server_tokens off; # Hide Nginx version from response headers
# ── MIME Types ───────────────────────────────────────
include /etc/nginx/mime.types;
default_type application/octet-stream;
# ── Buffers ──────────────────────────────────────────
client_body_buffer_size 128k;
client_max_body_size 10m;
client_header_buffer_size 1k;
large_client_header_buffers 4 16k;
output_buffers 1 32k;
postpone_output 1460;
# ── Gzip Compression ─────────────────────────────────
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6; # 1 (fastest) to 9 (best compression); 6 is a good balance
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml
font/woff2;
# ── Open File Cache ───────────────────────────────────
# Cache file descriptors to avoid repeated stat() syscalls
open_file_cache max=200000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# ── Logging ───────────────────────────────────────────
access_log /var/log/nginx/access.log combined buffer=512k flush=1m;
error_log /var/log/nginx/error.log warn;
include /etc/nginx/conf.d/*.conf;
}
Rate Limiting for APIs and Login Endpoints
Rate limiting in Nginx uses a leaky-bucket algorithm. Define a zone in the http block, then apply it to specific locations:
http {
# Define a rate limit zone: 10 req/sec per IP, 10MB state storage
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
}
server {
# Apply general rate limit with burst tolerance
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
# Stricter limit for login endpoint (brute force protection)
location /api/auth/login {
limit_req zone=login_limit burst=5;
limit_req_status 429;
proxy_pass http://backend;
}
}
The burst parameter allows a temporary spike above the rate. nodelay serves burst requests immediately instead of queuing them, which matters for APIs where clients retry quickly. One thing to watch: behind a load balancer or Cloudflare, $binary_remote_addr is the proxy's IP, so every user shares one bucket. Set up real_ip_header and set_real_ip_from first.
Security Hardening
Nginx sees every request before your app does, so it's a cheap place to drop obvious junk. The snippets below are a menu, not a drop-in file. Pattern-matching on query strings won't stop a real SQL injection attempt (your app's parameterized queries do that), and the 1k header buffers at the bottom are tight enough to break apps with large cookies or JWTs, causing 400 Bad Request: Request Header Or Cookie Too Large. Test with real traffic before shipping.
server {
# ── Restrict HTTP Methods ─────────────────────────────
if ($request_method !~ ^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$ ) {
return 405;
}
# ── Block Common Exploits ─────────────────────────────
# Block SQL injection attempts in query string
if ($query_string ~* "union.*select|insert.*into|drop.*table") {
return 403;
}
# Block requests with invalid characters in URI
if ($request_uri ~* "[;|`&\$]") {
return 400;
}
# ── Geo Blocking (requires ngx_http_geoip2_module) ────
# geo $blocked_country {
# default 0;
# CN 1; # Block China
# RU 1; # Block Russia
# }
# if ($blocked_country) { return 444; } # 444 = no response
# ── Deny Bot User Agents ──────────────────────────────
if ($http_user_agent ~* (scrapy|python-requests|libwww-perl|nikto|sqlmap)) {
return 403;
}
# ── Limit Request Size ────────────────────────────────
client_max_body_size 10m;
client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 10s;
# ── Buffer Overflow Protection ────────────────────────
client_body_buffer_size 1k;
client_header_buffer_size 1k;
large_client_header_buffers 2 1k;
}
After any configuration change, run sudo nginx -t && sudo nginx -s reload as a single command. The && means Nginx only reloads if the test passes, so a typo can't take down a production server.
What to Check Before Going to Production
You don't need everything above on day one. Start with the server block for your stack, add Certbot, and layer the rest in as you need it. Before pointing real traffic at the box, though, I'd run through this list:
Pre-launch Checklist
- Hidden files are blocked: request
https://yourdomain/.envand/.git/configyourself. You want a 403 or 404, not your database password. - Headers survive on every route:
curl -Ia static asset as well as a page, because of theadd_headerinheritance trap. - Your app sees real client IPs: log
request()->ip()(or the equivalent) once. If it shows 127.0.0.1, your trusted proxy setup is wrong, and so are your rate limits and audit logs. - Upload limits match:
client_max_body_sizein Nginx and the upload limits in PHP or your Node body parser should agree. - Renewal actually works:
certbot renew --dry-runpasses and the timer is enabled. Expired certs are still one of the most common self-inflicted outages. - Logs won't fill the disk: check that logrotate covers any custom log paths you added per site.
And when I wouldn't bother with most of this: on a PaaS like Heroku, Render, or Vercel, or behind a managed load balancer that already terminates TLS, hand-tuning Nginx buys you little. Inside a Kubernetes cluster you'll usually configure an ingress controller instead of writing raw server blocks. Hand-written Nginx config pays off when you own the VM, which for a lot of Laravel and Node.js projects is exactly the situation.