Core Web Vitals (Largest Contentful Paint, Interaction to Next Paint and Cumulative Layout Shift) measure how fast and stable a page feels to real users. Most of the work happens in the front end, but the server controls two things that feed directly into LCP: how quickly the first byte arrives (TTFB) and how many bytes the browser has to download. In this tutorial you will measure TTFB, then enable HTTP/2, gzip and Brotli compression, long-lived cache headers for static assets and a FastCGI page cache for a PHP site running on Nginx and Ubuntu 24.04.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Nginx and PHP-FPM installed (
sudo apt install nginx php-fpm). Ubuntu 24.04 ships Nginx 1.24 and PHP 8.3. - A site served by Nginx at
your_domain, with a Let's Encrypt certificate already in place (for example withsudo certbot --nginx -d your_domain). - A local machine with
curlto take measurements from outside the server.
Throughout the guide, replace your_domain with your own domain name.
Step 1 - Measuring a TTFB baseline
Before changing anything, record how long the server takes to answer. curl can break a request into its phases. Run this from your local machine, not from the server itself, so the numbers include real network latency:
curl -o /dev/null -s -w 'dns: %{time_namelookup}s connect: %{time_connect}s tls: %{time_appconnect}s ttfb: %{time_starttransfer}s total: %{time_total}s\n' https://your_domain/
dns: 0.012s connect: 0.041s tls: 0.089s ttfb: 0.612s total: 0.640s
The ttfb value includes DNS, TCP and TLS. The difference between ttfb and tls is roughly the time your application spent building the page. Run the command five or six times and note the typical value; the first request is often slower.
Google considers a TTFB under 0.8 seconds good for real users, but for a server in the same region aim for under 200 ms. For field data (what real visitors experience), check your site in PageSpeed Insights, which shows Chrome UX Report metrics when your site has enough traffic.
Step 2 - Enabling HTTP/2
HTTP/2 multiplexes all requests for a page over one TLS connection, which removes the per-connection setup cost for CSS, JavaScript, fonts and images. Nginx 1.24 on Ubuntu 24.04 enables it with a parameter on the listen directive.
Open your site's server block:
sudo nano /etc/nginx/sites-available/your_domain
Find the listen 443 ssl lines (Certbot adds them) and add http2:
listen 443 ssl http2;
listen [::]:443 ssl http2;
Test the configuration and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
Confirm the protocol from your local machine:
curl -sI --http2 https://your_domain/ | head -n 1
HTTP/2 200
NoteNginx 1.25.1 and later replace this parameter with a separate
http2 on;directive. If you installed Nginx from the nginx.org repository, use that form instead.
Step 3 - Compressing text responses with gzip and Brotli
HTML, CSS, JavaScript, JSON and SVG compress to a fraction of their size, which shortens the download of the HTML document and of render-blocking CSS, both of which delay LCP. Ubuntu's /etc/nginx/nginx.conf already contains gzip on;, but only compresses text/html. Extend it with a separate file in conf.d, which is loaded inside the http block:
sudo nano /etc/nginx/conf.d/compression.conf
# gzip is already switched on in nginx.conf; these settings extend it
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/css
text/plain
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
application/manifest+json
image/svg+xml
font/ttf
font/otf;
Do not add images such as JPEG, PNG, WebP or AVIF, or WOFF2 fonts: they are already compressed and gzip only wastes CPU on them.
Brotli compresses text about 15 to 20 percent better than gzip and every current browser supports it over HTTPS. Ubuntu 24.04 packages the Nginx Brotli module in the universe repository:
sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
The packages drop their load_module lines into /etc/nginx/modules-enabled/ automatically. Add the Brotli settings:
sudo nano /etc/nginx/conf.d/brotli.conf
brotli on;
brotli_comp_level 5;
brotli_static on;
brotli_types
text/css
text/plain
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
application/manifest+json
image/svg+xml
font/ttf
font/otf;
brotli_static on makes Nginx serve a pre-compressed app.css.br next to app.css when it exists, so if your build pipeline produces .br files, the server does no compression work at all for them. Level 5 is a good balance for on-the-fly compression; higher levels cost much more CPU for small gains.
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Check the Content-Encoding header of a CSS or JavaScript file on your site from your local machine. Replace the path with a real file:
curl -s -o /dev/null -D - -H 'Accept-Encoding: br, gzip' https://your_domain/css/app.css | grep -i content-encoding
content-encoding: br
Repeat with -H 'Accept-Encoding: gzip' and you should see content-encoding: gzip. If nothing is printed, the file is either smaller than gzip_min_length or its MIME type is not in the lists above.
Step 4 - Setting long cache lifetimes for static assets
Repeat visitors and anyone navigating between pages should not download the same CSS, JavaScript, fonts and images again. Most build tools (Vite, webpack, Laravel Mix, WordPress plugins) add a content hash or version string to asset URLs, which makes it safe to cache them for a year and mark them immutable, so the browser does not even revalidate them.
Add a location block inside the server block that listens on 443 in /etc/nginx/sites-available/your_domain:
location ~* \.(?:css|js|mjs|woff2|svg|ico|png|jpe?g|gif|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
Leave HTML without a long cache lifetime so users always get new pages after a deploy.
WarningOnly use
immutablewhen the file name changes whenever the content changes. If you deploy a newstyle.cssunder the same name, returning visitors will keep the old copy for up to a year.
Reload Nginx and check the headers:
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://your_domain/css/app.css | grep -i cache-control
cache-control: max-age=31536000
cache-control: public, max-age=31536000, immutable
Nginx emits one header from expires and one from add_header. Browsers use the most restrictive combination, which here is the same lifetime, so both lines are harmless. If you prefer a single header, remove the expires line.
Step 5 - Caching dynamic pages with the FastCGI cache
For a PHP site such as WordPress, most of the TTFB is PHP and database time. Nginx can store the rendered HTML and serve the next anonymous visitor straight from disk or memory, which usually brings TTFB for cached pages down to a few milliseconds of server time.
Define the cache zone in the http context:
sudo nano /etc/nginx/conf.d/fastcgi-cache.conf
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=PAGES:50m max_size=1g inactive=60m use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# Skip the cache for logged-in users and anyone with a session or cart cookie
map $http_cookie $skip_cache {
default 0;
~*(wordpress_logged_in|comment_author|woocommerce_items_in_cart|PHPSESSID) 1;
}
Adjust the cookie names in the map to whatever your application uses to identify logged-in users. keys_zone=PAGES:50m keeps the cache keys in shared memory (1 MB holds about 8,000 keys), and max_size limits the disk space used by cached pages.
Now enable the cache in the PHP location of your server block:
sudo nano /etc/nginx/sites-available/your_domain
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_cache PAGES;
fastcgi_cache_valid 200 301 10m;
fastcgi_cache_valid 404 1m;
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-Status $upstream_cache_status always;
}
The use_stale and background_update lines let Nginx keep serving the previous copy while it refreshes an expired page, so no visitor waits for PHP. fastcgi_cache_lock makes concurrent requests for the same uncached page wait for a single PHP call instead of all hitting the backend.
Create the cache directory, then test and reload:
sudo mkdir -p /var/cache/nginx/fastcgi
sudo nginx -t
sudo systemctl reload nginx
Request the same page twice and watch the X-Cache-Status header:
curl -sI https://your_domain/ | grep -i x-cache-status
curl -sI https://your_domain/ | grep -i x-cache-status
x-cache-status: MISS
x-cache-status: HIT
If you only ever see MISS or BYPASS, check whether the application sends a Set-Cookie header or Cache-Control: private / no-cache on that page: Nginx does not cache such responses by default. BYPASS means one of the cookies in the map was present.
Run the TTFB measurement from Step 1 again. A cached page should show ttfb close to the tls value.
To empty the cache after a deploy, delete its contents:
sudo find /var/cache/nginx/fastcgi -type f -delete
Step 6 - Making sure PHP OPcache is sized correctly
Pages that cannot be cached (logged-in users, checkouts, admin areas) still depend on PHP speed. OPcache keeps compiled PHP scripts in memory and is enabled by default for PHP-FPM on Ubuntu, but its default 128 MB can fill up on large applications, after which PHP starts recompiling files.
Check the current status:
php-fpm8.3 -i | grep -E '^opcache\.(enable|memory_consumption|max_accelerated_files) '
opcache.enable => On => On
opcache.max_accelerated_files => 10000 => 10000
opcache.memory_consumption => 128 => 128
For a large application such as WordPress with many plugins or a Laravel project, raise the limits in a separate override file:
sudo nano /etc/php/8.3/fpm/conf.d/99-opcache-tuning.ini
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
Restart PHP-FPM to apply it:
sudo systemctl restart php8.3-fpm
Leave opcache.validate_timestamps enabled unless your deploy process restarts PHP-FPM every time; otherwise changed files will not be picked up.
Troubleshooting
nginx -t reports unknown directive "brotli". The module packages are not installed or their files in /etc/nginx/modules-enabled/ are missing. Reinstall them with sudo apt install --reinstall libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static.
Nginx reports "gzip" directive is duplicate. You added gzip on; to compression.conf while it is already set in nginx.conf. Remove it from one of the two files.
Cache headers are missing from some files. A location block with its own add_header replaces all headers inherited from the server level. Make sure static files are handled by the location from Step 4 and not by another block that matches first.
TTFB is still high on cached pages. Compare tls and ttfb in the curl output. If the gap is small, the server responds quickly and the delay is network distance; place the server closer to your users or put a CDN in front of it.
Conclusion
You measured TTFB, enabled HTTP/2, compressed text assets with Brotli and gzip, gave versioned static files year-long cache lifetimes and served anonymous page views from the Nginx FastCGI cache. Together these changes remove most of the server-side delay in front of LCP. As next steps, send critical resources to the browser earlier with HTTP 103 Early Hints, compare other caching layers such as Varnish, Redis and Memcached, and track field data over time in PageSpeed Insights or Google Search Console.
