DevOps

Nginx Configuration for Web Apps

Mayur Dabhi
Mayur Dabhi
April 23, 2026
14 min read

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.

When to Choose Nginx

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.

1

Install on Ubuntu/Debian

Add the official Nginx repository for the latest stable version, then install via apt.

Terminal: Ubuntu/Debian
# 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
2

Install on CentOS/RHEL/Amazon Linux

Use the official Nginx RPM repository for rpm-based distros.

Terminal: CentOS/RHEL
# 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).

main context worker_processes, error_log, pid events { } worker_connections http { } gzip, log_format, include server { } listen, server_name location / { } location /api { } server { } port 443, SSL location / { } location ~ \.php$ { }

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

/etc/nginx/conf.d/example.com.conf
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;
}
Testing Your Config

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)

/etc/nginx/conf.d/laravel-app.conf
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 502 You'll Hit After a PHP Upgrade

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.

1

Install Certbot

Certbot is the client Let's Encrypt recommends. The snap package tracks the latest version.

Terminal: Install Certbot
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
2

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.

/etc/nginx/conf.d/example.com.conf (production SSL)
# 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;
    }
}
HSTS Warning

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.

Two Gotchas in This Config

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.

Internet Clients :443 Nginx SSL termination Reverse Proxy Static Files Node.js App localhost:3000 PHP-FPM unix socket Python API localhost:8000

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)
Load Balancing Configuration
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.

/etc/nginx/nginx.conf (production tuning)
# 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.

Security Configuration Snippets
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;
}
Nginx -t Is Your Best Friend

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/.env and /.git/config yourself. You want a 403 or 404, not your database password.
  • Headers survive on every route: curl -I a static asset as well as a page, because of the add_header inheritance 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_size in Nginx and the upload limits in PHP or your Node body parser should agree.
  • Renewal actually works: certbot renew --dry-run passes 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.

Nginx Server Configuration DevOps SSL Reverse Proxy Load Balancing
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.