Nginx performance tuning and caching on Ubuntu

Nginx performance tuning starts from a good place: Ubuntu’s package already uses one worker per CPU core, sendfile and gzip for HTML. What is left are a few settings that depend on your traffic and your site: connection limits, compression of CSS and JavaScript, browser caching of static files and a cache for pages from the app. The values below are starting points for Ubuntu 24.04 with Nginx 1.24 (22.04 has 1.18 with the same settings). Change one thing at a time and measure.

Workers and connections

worker_processes auto starts one worker per CPU core, and Ubuntu’s /etc/nginx/nginx.conf already has it. worker_connections is how many connections each worker can hold at once, and Ubuntu sets 768. One worker holds clients and connections to the app alike, so behind a proxy each visitor takes two. The rough limit is workers × worker_connections, halved when proxying. 4096 is a sane start for a busy site. Each connection is an open file, so raise the open file limit for workers with worker_rlimit_nofile, about twice worker_connections.

sudo nano /etc/nginx/nginx.conf

Change the top of the file and the events block:

/etc/nginx/nginx.conf
worker_processes auto; worker_rlimit_nofile 8192; events { worker_connections 4096; }
sudo nginx -t sudo systemctl reload nginx nproc ps -C nginx -o pid,args

nproc prints the number of cores, and ps should show one nginx: master process and as many nginx: worker process lines. Every connection needs an open file, and the service starts with a limit of 1024 per process, which is why worker_rlimit_nofile goes with a larger worker_connections.

Keepalive for visitors and for the app

Keepalive lets one connection carry many requests. For visitors it is on already: keepalive_timeout is 75 seconds by default and keepalive_requests is 1000. A shorter timeout, such as 30 seconds, frees connections sooner on a busy server. Between Nginx and the app, Nginx 1.24 opens a new connection for every request. An upstream block with keepalive keeps a few of them open, and the location needs HTTP/1.1 and an empty Connection header for that.

/etc/nginx/sites-available/app.example.com
upstream app_backend { server 127.0.0.1:3000; keepalive 16; } server { # listen and server_name as before keepalive_timeout 30s; location / { proxy_pass http://app_backend; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
sudo nginx -t sudo systemctl reload nginx

keepalive 16 is the number of idle connections each worker keeps to the app, not a limit on all of them. The basics of proxy_pass and these headers are in how to set up an Nginx reverse proxy. A WebSocket location there sets Connection from a map, so keep it as it is.

How to turn on gzip for CSS, JavaScript and JSON

Ubuntu’s nginx.conf has gzip on, but the list of types is commented out, so Nginx compresses only HTML. In the Gzip Settings part of /etc/nginx/nginx.conf, remove the # in front of the gzip lines and adjust them like this. Level 5 compresses text well at a small CPU cost. Higher levels give little extra. Images, video and archives are compressed already, so leave them out of the list.

/etc/nginx/nginx.conf
gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 5; gzip_min_length 256; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
sudo nginx -t sudo systemctl reload nginx curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' https://example.com/style.css

Put the address of a real CSS or JS file of your site in the last command. The headers should include Content-Encoding: gzip. The command makes a normal GET request: Nginx doesn’t compress answers to a HEAD request (curl with -I), so that check would show nothing.

Cache headers for static files and open_file_cache

expires adds Expires and Cache-Control: max-age to the answer, and the browser stops asking for the file until that time. A long time like 30 days is safe only for files whose name changes with each release, such as app.3f9a1c.js from a bundler. For files that keep their name, start with a few hours. Add a location like this to the server block of a site that serves files from root:

/etc/nginx/sites-available/example.com
location ~* \.(css|js|woff2|svg|png|jpg|jpeg|webp|avif|ico)$ { expires 30d; access_log off; }

open_file_cache keeps open file handles and the results of file lookups, so Nginx doesn’t look up the same files on disk for every request. It helps a site with many static files. The values are the example from the Nginx documentation. Put them in a file of their own in /etc/nginx/conf.d/, which Ubuntu reads inside the http block:

sudo nano /etc/nginx/conf.d/open-file-cache.conf
/etc/nginx/conf.d/open-file-cache.conf
open_file_cache max=1000 inactive=20s; open_file_cache_valid 30s; open_file_cache_min_uses 2; open_file_cache_errors on;
sudo nginx -t sudo systemctl reload nginx curl -sI https://example.com/style.css

The answer should have Cache-Control: max-age=2592000 (30 days in seconds) and an Expires date. With open_file_cache_valid 30s, Nginx may serve an old version of a changed file for up to 30 seconds after a deploy.

Nginx caching of pages: proxy_cache and fastcgi_cache

Nginx caching of whole answers takes load off the app: a cached page comes from disk without a call to Node.js, Python or PHP. It suits pages that are the same for every visitor, such as a blog, a catalogue or an API with public data. Nginx skips answers with Set-Cookie or with Cache-Control: private, no-cache or no-store on its own, but don’t turn the cache on for pages with personal data without checking what the app sends. First create the cache folder and describe the cache zone in the http block:

sudo mkdir -p /var/cache/nginx sudo nano /etc/nginx/conf.d/cache.conf
/etc/nginx/conf.d/cache.conf
proxy_cache_path /var/cache/nginx/app levels=1:2 keys_zone=app_cache:10m max_size=1g inactive=60m use_temp_path=off;

Then turn the cache on in the location that proxies to the app:

/etc/nginx/sites-available/app.example.com
location / { # proxy_pass and proxy_set_header lines as before proxy_cache app_cache; proxy_cache_valid 200 301 302 10m; proxy_cache_valid 404 1m; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_lock on; add_header X-Cache-Status $upstream_cache_status; }
sudo nginx -t sudo systemctl reload nginx curl -sI https://app.example.com/ | grep X-Cache-Status curl -sI https://app.example.com/ | grep X-Cache-Status

The first answer says X-Cache-Status: MISS, the second HIT. BYPASS or a constant MISS means the app sends headers that forbid caching. 10 MB of keys_zone holds about 80 thousand keys, and max_size limits the cache on disk. proxy_cache_use_stale serves the last good copy while the app is down or slow.

For PHP through PHP-FPM the same cache has fastcgi_ names. The key must be set by hand, and pages for visitors with a session cookie should go past the cache:

/etc/nginx/sites-available/example.com
location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; fastcgi_cache php_cache; fastcgi_cache_key "$scheme$request_method$host$request_uri"; fastcgi_cache_valid 200 10m; fastcgi_cache_bypass $cookie_PHPSESSID; fastcgi_no_cache $cookie_PHPSESSID; add_header X-Cache-Status $upstream_cache_status; }

It needs its own zone in /etc/nginx/conf.d/cache.conf: fastcgi_cache_path /var/cache/nginx/php levels=1:2 keys_zone=php_cache:10m max_size=1g inactive=60m use_temp_path=off;. To empty a cache, delete the files in its folder, for example:

sudo rm -rf /var/cache/nginx/app/*

This removes only cached copies, which Nginx fetches again from the app.

If you also work on a Mac

MacWashing, System Junk: Xcode junk, user caches, installers and logs found, 22.83 GB ready to be cleaned

System Junk in MacWashing finds the caches of every app it knows, including those hidden inside app data, plus logs, Xcode junk and leftovers of deleted apps. It shows how much each takes and clears them in one go. Anything debatable stays unchecked until you decide.

Free trial · macOS 14 or later

Questions and answers

What matters most in Nginx performance tuning?

For most sites: gzip for text files, long cache headers for static files with versioned names, and a page cache where pages are the same for everyone. Worker and connection limits matter once the server has many visitors at once.

Should I use worker_processes auto or a number?

auto sets one worker per core, which suits almost every server. A fixed number makes sense only if Nginx shares the server with something that needs cores of its own.

Is gzip level 9 better than 5?

Files get only slightly smaller, while the CPU works noticeably harder on every answer. Levels 4 to 6 are the usual choice.