Images are usually the largest part of a web page, and modern formats cut their size dramatically: WebP files are typically 25 to 35 percent smaller than equivalent JPEGs, and AVIF often halves them. In this tutorial you will build a small pipeline on Ubuntu 24.04 that generates WebP and AVIF copies of every JPEG and PNG on your site, lets Nginx pick the best format for each browser without changing your HTML, sets long cache headers and keeps new uploads converted automatically with a systemd timer.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS with at least 1 GB of RAM, for example a CubePath VPS. AVIF encoding is CPU intensive, so more cores make the first run faster.
  • A non-root user with sudo privileges, referred to as your_user.
  • Nginx installed from the Ubuntu repositories, serving your site over HTTPS from /var/www/your_domain.
  • Your images stored as regular files under that directory, for example in /var/www/your_domain/images, and owned by your_user.

How the pipeline works

The approach keeps your original files and URLs untouched:

  1. For each photo.jpg, a script creates photo.jpg.webp and photo.jpg.avif next to it.
  2. When a browser requests /images/photo.jpg, Nginx reads its Accept header. If it lists image/avif, Nginx serves photo.jpg.avif; if it lists image/webp, it serves photo.jpg.webp; otherwise it serves the original.
  3. A Vary: Accept header tells caches that the response depends on that header.
  4. A systemd timer runs the script every 15 minutes and only converts new or changed images.

Step 1 - Installing the image tools

Install the reference encoders for both formats. webp provides cwebp, and libavif-bin provides avifenc:

sudo apt update
sudo apt install webp libavif-bin

Check that both are available:

cwebp -version
avifenc --version

Each command prints its version: libwebp 1.3.2 and libavif 1.0.4 on Ubuntu 24.04. The avifenc line also lists the AV1 codecs it was built with.

Step 2 - Testing the conversion on one image

Before automating anything, convert a single image to find a quality level you are happy with. Pick a representative photo:

cd /var/www/your_domain/images
cwebp -quiet -q 80 photo.jpg -o photo.jpg.webp
avifenc -q 60 -s 6 photo.jpg photo.jpg.avif

For cwebp, -q is the quality from 0 to 100. For avifenc, -q is also 0 to 100 (100 is lossless) and -s is the encoder speed from 0 (slowest, smallest) to 10 (fastest). Speed 6 is a good compromise for batch jobs.

Compare the sizes:

ls -lh photo.jpg*
-rw-r--r-- 1 your_user your_user 412K Sep 25 10:02 photo.jpg
-rw-r--r-- 1 your_user your_user 168K Sep 25 10:04 photo.jpg.avif
-rw-r--r-- 1 your_user your_user 251K Sep 25 10:04 photo.jpg.webp

Open the three files side by side in a browser (drag them into separate tabs) and check for visible artifacts. If you see banding or blur, raise the quality by 5 and try again. Values between 75 and 85 for WebP and 55 and 70 for AVIF work well for most photos.

Step 3 - Writing the conversion script

A script makes the conversion repeatable and skips images that are already up to date. Create it:

sudo nano /usr/local/bin/optimize-images

Add the following content:

#!/usr/bin/env bash
# Create .webp and .avif variants next to every JPEG and PNG under a directory.
# Only new or modified images are converted, so it is safe to run repeatedly.
set -euo pipefail

dir="${1:?usage: optimize-images DIRECTORY}"
webp_quality="${WEBP_QUALITY:-80}"
avif_quality="${AVIF_QUALITY:-60}"
written=0

while IFS= read -r -d '' src; do
    if [[ ! -e "${src}.webp" || "$src" -nt "${src}.webp" ]]; then
        cwebp -quiet -q "$webp_quality" "$src" -o "${src}.tmp.webp"
        mv -f "${src}.tmp.webp" "${src}.webp"
        written=$((written + 1))
    fi
    if [[ ! -e "${src}.avif" || "$src" -nt "${src}.avif" ]]; then
        avifenc -q "$avif_quality" -s 6 "$src" "${src}.tmp.avif" > /dev/null
        mv -f "${src}.tmp.avif" "${src}.avif"
        written=$((written + 1))
    fi
done < <(find "$dir" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) -print0)

echo "optimize-images: ${written} files written under ${dir}"

The script uses find -print0 with read -d '' so file names with spaces work, and writes each result to a temporary name before renaming it, so Nginx never serves a half-written file. Both qualities can be overridden with the WEBP_QUALITY and AVIF_QUALITY environment variables.

Make it executable:

sudo chmod 755 /usr/local/bin/optimize-images

Run it once on your image directory as the user that owns the files. The first run can take a while on large libraries because of AVIF:

optimize-images /var/www/your_domain/images
optimize-images: 842 files written under /var/www/your_domain/images

Run it again. Nothing should be converted this time:

optimize-images: 0 files written under /var/www/your_domain/images

Step 4 - Configuring Nginx to serve the best format

Nginx needs to translate the Accept header into a file suffix. A map block does this once per request and must live in the http context, so put it in its own file under conf.d:

sudo nano /etc/nginx/conf.d/image-formats.conf
map $http_accept $img_suffix {
    default         "";
    "~*image/avif"  ".avif";
    "~*image/webp"  ".webp";
}

Regular expressions in a map are checked in order, so browsers that accept both formats get AVIF.

Now open your site's server block:

sudo nano /etc/nginx/sites-available/your_domain

Add this location inside the server block, before any other regex location that matches images:

location ~* \.(?:jpe?g|png)$ {
    add_header Vary Accept;
    add_header Cache-Control "public, max-age=2592000";
    try_files $uri$img_suffix $uri =404;
}

location ~* \.(?:webp|avif)$ {
    add_header Cache-Control "public, max-age=2592000";
}

try_files first looks for the file with the suffix (photo.jpg.avif) and falls back to the original if that variant does not exist. Nginx sets Content-Type from the extension of the file it actually serves, and the mime.types file shipped with Ubuntu 24.04 already maps .webp and .avif. You can confirm that:

grep -E 'avif|webp' /etc/nginx/mime.types
    image/avif                                       avif;
    image/webp                                       webp;

The cache lifetime here is 30 days rather than a year because the URL stays the same when you replace an image. Test the configuration and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Step 5 - Verifying content negotiation

Request the same URL with three different Accept headers and check the content type:

curl -s -o /dev/null -D - -H 'Accept: image/avif,image/webp,*/*' https://your_domain/images/photo.jpg | grep -i -E 'content-type|content-length|vary'
content-type: image/avif
content-length: 171913
vary: Accept
curl -s -o /dev/null -D - -H 'Accept: image/webp,*/*' https://your_domain/images/photo.jpg | grep -i content-type
content-type: image/webp
curl -s -o /dev/null -D - https://your_domain/images/photo.jpg | grep -i content-type
content-type: image/jpeg

In Chrome or Firefox, open DevTools, go to the Network tab and filter by Img. The Type column shows avif for your JPEG URLs.

Step 6 - Automating conversion with a systemd timer

New uploads need their variants too. A systemd timer runs the script regularly with low priority, logs to the journal and does not overlap with itself.

Create the service unit:

sudo nano /etc/systemd/system/optimize-images.service
[Unit]
Description=Generate WebP and AVIF variants of site images

[Service]
Type=oneshot
User=your_user
ExecStart=/usr/local/bin/optimize-images /var/www/your_domain/images
Nice=10
IOSchedulingClass=idle

Create the timer that triggers it every 15 minutes:

sudo nano /etc/systemd/system/optimize-images.timer
[Unit]
Description=Run optimize-images every 15 minutes

[Timer]
OnBootSec=5min
OnUnitActiveSec=15min

[Install]
WantedBy=timers.target

Load the units and enable the timer:

sudo systemctl daemon-reload
sudo systemctl enable --now optimize-images.timer

Confirm that it is scheduled:

systemctl list-timers optimize-images.timer
NEXT                        LEFT     LAST PASSED UNIT                  ACTIVATES
Thu 2026-09-25 10:40:12 UTC 4min 58s -    -      optimize-images.timer optimize-images.service

Trigger a run manually and read its log:

sudo systemctl start optimize-images.service
journalctl -u optimize-images.service -n 5 --no-pager
Sep 25 10:35:20 web systemd[1]: Starting optimize-images.service - Generate WebP and AVIF variants of site images...
Sep 25 10:35:20 web optimize-images[3812]: optimize-images: 0 files written under /var/www/your_domain/images
Sep 25 10:35:20 web systemd[1]: optimize-images.service: Deactivated successfully.

If your application stores uploads in another directory, add a second ExecStart= line for it in the service. A oneshot service runs them one after the other.

Step 7 - Loading images efficiently in HTML

Smaller files help most when the browser also loads them at the right time. Add loading="lazy" to images below the fold and always set width and height so the page does not jump while images load:

<img src="/images/photo.jpg" alt="Data center aisle" width="1200" height="800" loading="lazy">

Leave loading="lazy" off the main image at the top of the page, because delaying it hurts Largest Contentful Paint.

If your site sits behind a CDN that ignores Vary: Accept, the CDN may cache the AVIF version and serve it to a browser that cannot display it. In that case, remove the negotiation from Nginx and let the browser choose with a <picture> element instead, which works with any cache:

<picture>
  <source srcset="/images/photo.jpg.avif" type="image/avif">
  <source srcset="/images/photo.jpg.webp" type="image/webp">
  <img src="/images/photo.jpg" alt="Data center aisle" width="1200" height="800" loading="lazy">
</picture>

Troubleshooting

The browser still receives image/jpeg. Check that the variant exists next to the original (ls /var/www/your_domain/images/photo.jpg.*) and that no other regex location earlier in the server block matches .jpg first. Nginx uses the first matching regex location.

The service fails with "Permission denied". The user in User= cannot write to the image directory. Make sure your_user owns it, or change User= to the owner.

The first run takes hours. AVIF encoding is slow on large libraries. Run the first conversion manually with a faster speed, for example by temporarily changing -s 6 to -s 8 in the script, then restore it for incremental runs.

avifenc fails on some PNG files. 16-bit or unusual PNGs can be rejected. The script stops at the first error because of set -e; check the journal for the file name and re-export that image as a standard 8-bit PNG.

Conclusion

Your server now generates WebP and AVIF versions of every image, serves the smallest format each browser supports from the original URL, and keeps new uploads converted automatically. Measure the gain with Lighthouse or the transfer size column in DevTools. As next steps, you can generate several widths per image for srcset, add an on-the-fly resizing service such as imgproxy for user-generated content, or put a CDN in front of the images.