On a small VPS with 512 MB to 2 GB of RAM, Nginx gets noticeably faster with a handful of changes: right-sized workers, keepalive to clients and upstreams, compression, TLS session reuse, HTTP/2 or HTTP/3, browser cache headers and a short FastCGI or proxy cache. This guide walks through each one with configs for Debian 12/13 and Ubuntu 22.04/24.04 (with notes for Rocky Linux/AlmaLinux), and shows how to measure the result.
sudo cp -a /etc/nginx /root/nginx-backup-$(date +%F).Step 1: Measure a baseline
Measure first, so you can tell what actually helped. For a single request, curl shows where the time goes:
curl -so /dev/null -w 'dns %{time_namelookup}s connect %{time_connect}s tls %{time_appconnect}s ttfb %{time_starttransfer}s total %{time_total}s http/%{http_version}\n' https://example.com/
connect minus dns is roughly the network RTT, tls is the handshake, and ttfb (time to first byte) minus tls is how long the server needed to produce the page. A high ttfb on a PHP page points to the application, which is where caching (Step 7) helps most.
For throughput under load, use wrk, ab or hey, ideally from another machine so the test tool does not compete with Nginx for CPU:
# Debian/Ubuntu: sudo apt install wrk apache2-utils
# Rocky/Alma: sudo dnf install httpd-tools
wrk -t2 -c50 -d30s https://example.com/
ab -n 2000 -c 20 -k https://example.com/
hey -n 2000 -c 50 https://example.com/
Note requests per second and latency, and run the same command after each change.
Step 2: Check your Nginx version and modules
nginx -v
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'http_v2|http_v3'
ls /etc/nginx/modules-enabled/ 2>/dev/null
nproc; free -m
grep -nE 'worker_|gzip|keepalive_timeout|access_log|ssl_session' /etc/nginx/nginx.conf
The version decides which syntax you can use. Debian 12 ships 1.22, Debian 13 ships 1.26, Ubuntu 22.04 ships 1.18 and Ubuntu 24.04 ships 1.24. HTTP/3 needs Nginx 1.25 or newer built with --with-http_v3_module; if your distribution package lacks it, the official nginx.org repository provides current builds that include it. The last command shows settings that already exist in nginx.conf: comment out gzip on;, keepalive_timeout, access_log and any ssl_session_* lines there before Step 4, otherwise nginx -t reports a duplicate directive or every request is logged twice.
Step 3: Size the workers
# /etc/nginx/nginx.conf - main context (outside http {})
worker_processes auto;
worker_rlimit_nofile 8192;
events {
worker_connections 2048;
}
worker_processes auto starts one worker per CPU core. Each worker can hold worker_connections connections at once, and a proxied request uses two (client and upstream). worker_rlimit_nofile raises the open-file limit for workers; keep it at least twice worker_connections. Suggested values:
| Plan | worker_connections | worker_rlimit_nofile |
|---|---|---|
| mini (1 core, 512 MB) | 1024 | 4096 |
| 01 (2 cores, 1 GB) | 2048 | 8192 |
| 02 (3 cores, 2 GB) | 4096 | 16384 |
Nginx itself uses little memory per connection. On small plans the real limit is usually PHP-FPM or your application, so size those pools to the RAM you have.
Step 4: Keepalive, compression, TLS sessions, file cache and logging
Put the shared http-level settings in one file. Both Debian/Ubuntu and Rocky/Alma load /etc/nginx/conf.d/*.conf inside http {}:
sudo tee /etc/nginx/conf.d/00-performance.conf > /dev/null <<'EOF'
# Loaded inside http {} by nginx.conf
# Keepalive with clients
keepalive_timeout 30s;
keepalive_requests 1000;
# Compression
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript
application/json application/xml application/rss+xml image/svg+xml;
# TLS session reuse
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Cache open file descriptors and metadata
open_file_cache max=2000 inactive=60s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# Buffered access log
access_log /var/log/nginx/access.log combined buffer=64k flush=5s;
EOF
- Keepalive: browsers reuse one connection for many requests, which saves a TCP and TLS handshake each time. 30 seconds is a good balance on a small server; very long timeouts only keep idle connections open.
- Gzip: level 5 gives most of the size reduction for little CPU.
text/htmlis always compressed, so it is not in the list. Do not add images such as JPEG or PNG, or archives: they are already compressed. - TLS session cache: returning visitors resume their TLS session instead of doing a full handshake. 10 MB holds roughly 40,000 sessions.
- open_file_cache: keeps file handles and metadata of frequently served files. After a deploy, changed files can take up to
open_file_cache_valid(60 s) to be noticed. - Buffered access log: Nginx writes the log in 64 KB chunks or every 5 seconds instead of once per request, which cuts disk writes. Logs appear with a short delay.
Brotli (optional)
Brotli compresses text assets about 15–20% better than gzip, but it is not part of standard Nginx. Some Debian and Ubuntu releases package it as a dynamic module; check first:
apt-cache search libnginx-mod-http-brotli
sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
If the search returns nothing, your release does not package it, and Rocky/Alma base repositories do not include it either. In that case simply stay with gzip; it is perfectly fine. If the module installs, add:
# Add to /etc/nginx/conf.d/00-performance.conf only if the module is installed
brotli on;
brotli_comp_level 5;
brotli_types text/plain text/css text/xml text/javascript application/javascript
application/json application/xml application/rss+xml image/svg+xml;
Step 5: Keepalive to the upstream (reverse proxy)
If Nginx proxies to an application (Node.js, Python, Go and so on), keep a pool of idle connections to it instead of opening a new one per request:
upstream app_backend {
server 127.0.0.1:3000;
keepalive 16;
}
server {
# ... listen, server_name, certificates ...
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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;
}
}
keepalive 16 is the number of idle connections each worker keeps. Upstream keepalive only works with proxy_http_version 1.1 and an empty Connection header. If the app uses WebSockets, give that location its own Upgrade/Connection headers instead.
Step 6: Enable HTTP/2 and HTTP/3
HTTP/2 sends many requests over one connection. HTTP/3 runs over QUIC (UDP) and handles packet loss and network changes better, which helps mobile users and long-distance visitors. For Nginx 1.25.1 and newer:
server {
listen 443 ssl;
listen 443 quic reuseport;
http2 on;
http3 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
root /var/www/example.com;
}
reuseportmay appear only once per address and port across all server blocks. Other sites on the same IP use justlisten 443 quic;.- QUIC always uses TLS 1.3, so keep
TLSv1.3enabled. - The
Alt-Svcheader tells browsers that HTTP/3 is available; the first visit uses HTTP/2, and later requests switch to HTTP/3. - If you use IPv6, add the same lines with
[::]:443.
On older Nginx (Ubuntu 22.04/24.04 and Debian 12 packages), http2 on; does not exist and HTTP/3 is not available. Enable HTTP/2 on the listen line instead:
listen 443 ssl http2;
HTTP/3 also needs UDP port 443 open in your firewall (see how to configure the firewall):
# Ubuntu/Debian with ufw
sudo ufw allow 443/udp
# Rocky/Alma with firewalld
sudo firewall-cmd --permanent --add-port=443/udp
sudo firewall-cmd --reload
Check the header, and if your curl was built with HTTP/3 support (curl -V lists HTTP3), test it directly. Otherwise look at the Protocol column in your browser’s developer tools, which shows h3:
curl -sI https://example.com/ | grep -i alt-svc
curl -sI --http3-only https://example.com/ | head -n 1
Step 7: Cache static files and dynamic pages
Browser cache headers for static files
# Static files: let browsers cache them
location ~* \.(?:css|js|mjs|woff2|svg|png|jpe?g|gif|webp|avif|ico)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
# Versioned assets (file name contains a hash, e.g. app.3f9a1c.js)
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Alt-Svc 'h3=":443"; ma=86400' always;
access_log off;
}
Use the one-year immutable rule only for files whose names change when their content changes; otherwise visitors keep old versions. Watch out for one Nginx rule: if a location has any add_header, it no longer inherits add_header lines from the server block. That is why Alt-Svc is repeated there; do the same for HSTS or other security headers.
Create the cache directories
sudo mkdir -p /var/cache/nginx/fastcgi /var/cache/nginx/proxy
sudo chown -R www-data:www-data /var/cache/nginx # nginx:nginx on Rocky/Alma
FastCGI microcache for PHP
Caching PHP output for just 10 seconds can turn a busy page from hundreds of PHP runs per second into one run every 10 seconds, while content stays almost live:
# /etc/nginx/conf.d/10-fastcgi-cache.conf (http context)
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=PHPCACHE:32m
max_size=256m inactive=10m use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# Inside the server {} block of the PHP site
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($http_cookie ~* "PHPSESSID|wordpress_logged_in|wp-postpass|comment_author|woocommerce_items_in_cart") { set $skip_cache 1; }
if ($request_uri ~* "^/(wp-admin|wp-login\.php|cart|checkout|my-account)") { set $skip_cache 1; }
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_cache PHPCACHE;
fastcgi_cache_valid 200 301 302 10s;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_background_update on;
fastcgi_cache_lock on;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache $upstream_cache_status always;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
}
Change the socket to your PHP version: php8.2-fpm on Debian 12, php8.4-fpm on Debian 13, php8.1-fpm on Ubuntu 22.04, php8.3-fpm on Ubuntu 24.04. On Rocky/Alma, use include fastcgi_params; with fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; and the socket unix:/run/php-fpm/www.sock. Logged-in users, POST requests, query strings, carts and admin pages are never cached. Nginx also skips caching responses that set a cookie or send Cache-Control: private or no-store.
$http_cookie line, and test as a logged-in user before going live.proxy_cache for an application
# http context, e.g. /etc/nginx/conf.d/10-proxy-cache.conf
proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=APPCACHE:16m
max_size=512m inactive=60m use_temp_path=off;
# Add inside location / of the upstream example above
proxy_cache APPCACHE;
proxy_cache_key $scheme$host$request_uri;
proxy_cache_valid 200 301 1m;
proxy_cache_valid 404 10s;
proxy_cache_use_stale error timeout updating http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_bypass $http_authorization $cookie_session;
proxy_no_cache $http_authorization $cookie_session;
add_header X-Cache $upstream_cache_status always;
Requests with an Authorization header or a session cookie (rename it to match your app) bypass the cache. If the app sends its own Cache-Control or Expires headers, Nginx follows them instead of proxy_cache_valid. On Rocky/Alma, SELinux blocks Nginx from connecting to a local port and may block the cache directory until you run:
sudo setsebool -P httpd_can_network_connect 1
sudo restorecon -Rv /var/cache/nginx
Step 8: Test and reload
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://example.com/ | grep -iE 'x-cache|content-encoding|alt-svc'
nginx -t checks the syntax before anything changes; reload applies the new config without dropping connections. Request a cached page twice: X-Cache should change from MISS to HIT. Then rerun your baseline from Step 1 and compare. If nginx -t fails, it names the file and line; fix it or restore your backup from /root/.
FAQ
Which change gives the biggest gain?
For PHP sites such as WordPress, the FastCGI microcache by far, because it removes PHP from most requests. For static sites, compression and browser cache headers. Worker tuning mostly matters once you have many concurrent visitors.
How do I clear the cache after updating content?
Delete the cache files and reload: sudo rm -rf /var/cache/nginx/fastcgi/* (or proxy/*), then sudo systemctl reload nginx. With a 10-second microcache you can usually just wait.
Is a 512 MB VPS enough for Nginx?
Nginx itself, yes: it serves static files and cached pages with very little memory. The limit on the mini plan is usually PHP-FPM or a database, so keep those pools small and let the cache absorb traffic. If you need help, open a ticket; JUSTG offers 24/7 AI support by ticket.