Nginx serves more than 30% of all websites on the internet. Its high performance, predictable memory consumption, and ability to handle tens of thousands of simultaneous connections make it the de facto standard for serious web projects. However, there is a wide gap between “installing Nginx” and “configuring Nginx correctly”—one that even experienced developers can fall into.
This article is a practical guide, covering everything from the basic configuration structure to advanced caching and DDoS protection techniques. If you need to quickly put together a configuration for a specific task, use the Nginx Configuration Generator on reChecker—it creates an optimized configuration in a few clicks.
Nginx configuration structure
The configuration is built around a hierarchy of contexts—nested blocks, each responsible for its own scope. The main file is located at /etc/nginx/nginx.conf:
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}The worker_processes auto directive creates one worker process per CPU core. The worker_connections parameter sets the maximum number of simultaneous connections per process, for a total capacity of worker_processes × worker_connections. The use epoll directive specifies the Linux multiplexing mechanism (on FreeBSD, kqueue).
Directives in a parent context are inherited by child contexts and can be overridden. Site configurations are placed in /etc/nginx/sites-available/, with symbolic links from /etc/nginx/sites-enabled/:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo nginx -s reloadAlways run nginx -t before reloading— a syntax error can cause the server to stop.
Virtual hosts (server blocks)
A server block is the equivalent of an Apache virtual host. Minimal configuration:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}The server_name parameter accepts exact names, wildcards, and regular expressions. Priority: exact name → wildcard at the beginning (*.example.com) → wildcard at the end (example.*) → regular expression. If no block matches, the request goes to the default server:
server {
listen 80 default_server;
server_name _;
return 444;
}Code 444 is an Nginx-specific response that closes the connection without sending headers. It is useful for blocking requests by IP address.
Serving static files
Nginx serves static files significantly faster than any application server:
location ~* \.(jpg|jpeg|png|gif|ico|webp|avif|svg)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(css|js)$ {
expires 7d;
add_header Cache-Control "public";
access_log off;
}
location ~* \.(woff2?|ttf|eot|otf)$ {
expires 365d;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
access_log off;
}
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}The immutable parameter in Cache-Control tells the browser that the file will not change, eliminating repeat requests to the server entirely. The difference between root and alias: the former appends the location path to the root, while the latter replaces the location entirely.
SPA applications need a fallback:
location / {
try_files $uri $uri/ /index.html;
}Proxying (reverse proxy)
Node.js
upstream node_backend {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
keepalive 32;
}
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://node_backend;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}The keepalive 32 directive supports a pool of persistent connections to the backend. The Upgrade and Connection headers are required for WebSocket.
PHP-FPM
server {
listen 80;
server_name php-app.example.com;
root /var/www/php-app/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300s;
}
location ~* /uploads/.*\.php$ {
deny all;
}
}Using a Unix socket instead of TCP provides a performance gain of 5–10% by avoiding TCP stack overhead.
Python (Gunicorn)
upstream gunicorn_backend {
server unix:/run/gunicorn/app.sock fail_timeout=0;
}
server {
listen 80;
server_name python-app.example.com;
client_max_body_size 10M;
location / {
proxy_pass http://gunicorn_backend;
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;
}
location /static/ {
alias /var/www/python-app/staticfiles/;
expires 30d;
}
}For load balancing across backends, Nginx supports round robin (the default), weighted round robin, least_conn (least loaded), and ip_hash (client-to-server affinity).
SSL/TLS configuration
To check the current certificate, use the SSL Checker on reChecker.
Let's Encrypt
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-runOptimal configuration
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
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;
ssl_prefer_server_ciphers off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_dhparam /etc/nginx/dhparam.pem;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}OCSP Stapling removes the need for the browser to check the certificate's revocation status itself, speeding up the TLS handshake by 100–300 ms. Put shared SSL parameters in /etc/nginx/snippets/ssl-params.conf and include them using include—do not duplicate them in every server block.
Redirects
HTTP → HTTPS
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}www → non-www
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}Both variants (www and non-www) must have a valid SSL certificate—the browser checks it before receiving the redirect instruction.
Bulk redirects using map
map $request_uri $redirect_url {
/old-page-1 /new-page-1;
/products/123 /catalog/item-123;
/about-us /company;
}
server {
if ($redirect_url) {
return 301 $redirect_url;
}
}The map directive is processed during initialization and does not affect performance, even with thousands of entries.
Caching
Proxy cache
proxy_cache_path /var/cache/nginx/proxy
levels=1:2
keys_zone=proxy_cache:10m
max_size=1g
inactive=60m
use_temp_path=off;
server {
location / {
proxy_pass http://backend;
proxy_cache proxy_cache;
proxy_cache_valid 200 10m;
proxy_cache_valid 404 30s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
proxy_cache_key "$scheme$request_method$host$request_uri";
add_header X-Cache-Status $upstream_cache_status;
}
location /api/ {
proxy_pass http://backend;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
}proxy_cache_use_stale serves a stale version instead of 502 if the backend fails. The proxy_cache_lock on directive prevents a thundering herd: only one request per URL goes to the backend, while the others wait for the cache.
FastCGI cache
fastcgi_cache_path /var/cache/nginx/fastcgi
levels=1:2 keys_zone=fcgi_cache:10m
max_size=512m inactive=30m;
map $request_uri $skip_cache {
default 0;
~*/wp-admin 1;
~*/cart 1;
~*/checkout 1;
}
server {
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_cache fcgi_cache;
fastcgi_cache_valid 200 10m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
}
}Compression
Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/plain text/css text/javascript
application/json application/javascript
application/xml image/svg+xml;Level 5 offers the optimal balance: 6–9 provide minimal gains while noticeably increasing CPU load. The gzip_vary on directive adds Vary: Accept-Encoding for correct operation with a CDN.
Brotli
Brotli provides 15–25% better compression than Gzip:
brotli on;
brotli_comp_level 6;
brotli_min_length 256;
brotli_static on;
brotli_types text/plain text/css text/javascript
application/json application/javascript
application/xml image/svg+xml;The brotli_static on directive serves pre-compressed .br files, providing maximum compression without runtime CPU load.
Security
To check security headers, use the Security Headers tool.
Rate limiting
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_req_status 429;
server {
limit_req zone=general burst=20 nodelay;
location /login {
limit_req zone=login burst=3 nodelay;
}
}Security headers
add_header X-Frame-Options "SAMEORIGIN" 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;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;" always;DDoS and bot protection
client_max_body_size 10m;
client_body_timeout 12s;
client_header_timeout 12s;
map $http_user_agent $bad_bot {
default 0;
~*sqlmap 1;
~*nikto 1;
~*scanner 1;
}
server {
if ($bad_bot) { return 403; }
}Protecting sensitive paths
location ~ /\.git { deny all; return 404; }
location ~* \.(env|ini|log|conf|bak|sql)$ { deny all; return 404; }Logging and monitoring
Log formats
log_format detailed '$remote_addr [$time_local] "$request" '
'$status $body_bytes_sent rt=$request_time '
'urt=$upstream_response_time cs=$upstream_cache_status';
log_format json escape=json '{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request":"$request",'
'"status":$status,'
'"request_time":$request_time,'
'"upstream_response_time":"$upstream_response_time"'
'}';JSON format is convenient for integration with ELK Stack and Loki + Grafana.
Conditional logging
map $http_user_agent $loggable {
default 1;
~*monitoring 0;
~*UptimeRobot 0;
}
access_log /var/log/nginx/access.log detailed if=$loggable;Monitoring with stub_status
server {
listen 127.0.0.1:8080;
location /nginx_status {
stub_status on;
allow 127.0.0.1;
deny all;
}
}The endpoint returns metrics: active connections, request count, and connections in reading/writing/waiting states. It can be connected to Prometheus via nginx-prometheus-exporter.
Common errors and debugging
Location processing order
The most common mistake is misunderstanding priorities. Nginx selects a location in the following order:
= /exact— exact match (highest priority)^~ /prefix— prefix match that stops regex search~ \.php$— regular expression (case-sensitive)~* \.jpg$— regular expression (case-insensitive)/prefix— ordinary prefix (longest match)
Troubleshooting
sudo nginx -t # Проверка синтаксиса
sudo nginx -T # Полная конфигурация
sudo tail -50 /var/log/nginx/error.log
sudo ss -tlnp | grep nginx # Проверка портовCommon issues
502 Bad Gateway — the backend is unavailable. Check whether the process is running (systemctl status php8.3-fpm) and whether the Unix socket permissions are correct.
413 Request Entity Too Large — client_max_body_size has been exceeded; set the required value in the server or location.
Infinite redirect loop — a conflict between Nginx and the application. Solution: put the HTTP→HTTPS redirect in a separate server block, and pass X-Forwarded-Proto in the main block:
server {
listen 80;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
proxy_set_header X-Forwarded-Proto $scheme;
location / { proxy_pass http://backend; }
}Permission denied — Nginx runs as user www-data, who must have access to the site files:
sudo chown -R www-data:www-data /var/www/site
sudo find /var/www/site -type d -exec chmod 755 {} \;
sudo find /var/www/site -type f -exec chmod 644 {} \;Testing
curl -I -H "Host: example.com" http://127.0.0.1
curl -I http://example.com
curl -H "Accept-Encoding: gzip" -I https://example.comConclusion
Nginx configuration is an ongoing process. As load grows, caching rules are added, security policies are tightened, and proxying parameters are fine-tuned.
Key principles:
- Always check the configuration with
nginx -tbefore applying it. - Use
includeto put repeated blocks in separate files. - Configure SSL according to current recommendations—protocols and ciphers become outdated.
- Do not neglect rate limiting and security headers, even on small projects.
- Log meaningfully: JSON format and conditional logging simplify analysis.
- Test under load: a configuration that works at 10 RPS may be suboptimal at 10 000.
For a quick start, use the Nginx Configuration Generator on reChecker—it creates a configuration that accounts for SSL, compression, and security headers. Check the result with the SSL Checker and Security Headers, and for Apache configuration, use the .htaccess generator.