KBeezie

There's no place like ::1

Menu
  • Home
  • Start Here
  • Security Series
  • About

Serving WebP Without a WordPress Plugin: nginx + CLI

2026/05/20

Images make up the bulk of a web page's payload. A photo-heavy WordPress site can push several megabytes of JPGs and PNGs before the browser renders a single paragraph of text. WebP cuts that by 40–75% at the same visual quality — the difference between a page that loads in three seconds and one that loads in one.

WordPress plugin authors know this, and there are dozens of WebP conversion plugins available. The problem is how most of them work: they either require exec() in PHP (a security hole on a properly locked-down server), upload your images to a third-party API for conversion (a privacy concern and a dependency you don't control), or rewrite image URLs in the database and leave fallback gaps for browsers that still don't support WebP — Safari on older iOS, RSS readers, some crawlers.

The alternative: leave WordPress completely alone. Convert images to .webp siblings on disk with a command-line tool. Let nginx serve the WebP version when the browser says it can handle it, with the original JPG as automatic fallback. No database changes. No plugin overhead. No exec(). WordPress keeps storing and referencing .jpg exactly as it always has — nginx does the content negotiation at the edge.


Step 1: Install cwebp

apt update && apt install webp

The webp package on Debian/Ubuntu includes cwebp (the encoder) and dwebp (the decoder). All you need.


Step 2: Convert existing images

A script that walks every wp-content/uploads/ directory, creates a .webp sibling for each JPG or PNG, and discards it if the WebP ends up larger than the original — which happens occasionally with already-compressed images.

cd /home/user/www

for site in site1.com site2.com site3.com site4.com; do
    echo "=== $site ==="
    uploads="/home/user/www/$site/public_html/wp-content/uploads"
    cd "$uploads" || continue
    find . -type f \( -name "*.jpg" -o -name "*.jpeg" -o -name "*.png" \) | while read img; do
        webp="${img}.webp"
        if [ ! -f "$webp" ]; then
            cwebp -q 82 "$img" -o "$webp" 2>/dev/null
            if [ -f "$webp" ] && [ "$(stat -c%s "$webp")" -ge "$(stat -c%s "$img")" ]; then
                rm "$webp"
            fi
        fi
    done
done

The -q 82 parameter balances quality and compression. Go higher (85–90) for less loss, lower (75–80) for more aggressive savings. The size guard at the end prevents storing a WebP that's actually heavier — cwebp won't always win, and there's no point serving a "smaller" format that isn't.

This runs once. It'll peg a CPU core for a few minutes depending on how many images you have, then it's done.


Step 3: nginx content negotiation

Two additions to nginx.

First, a map block in nginx.conf — inside the http {} block, before the include sites-enabled/*; line:

# WebP content negotiation
map $http_accept $webp_suffix {
    default    "";
    "~*image/webp" ".webp";
}

This creates a variable $webp_suffix that equals .webp when the browser's Accept header includes image/webp, and is empty otherwise. It's evaluated once per request, in C, at the nginx level — zero PHP involvement.

Second, split your image location block. Before, you probably have something like:

location ~* \.(png|jpg|jpeg|gif|ico|webp|woff2|woff|ttf)$ {
    try_files $uri @404static;
    expires max;
    add_header Cache-Control "public, no-transform, immutable";
    aio threads;
}

Replace it with two blocks:

# Images with optional WebP sibling
location ~* \.(png|jpg|jpeg)$ {
    try_files $uri$webp_suffix $uri @404static;
    expires max;
    add_header Vary Accept;
    add_header Cache-Control "public, no-transform, immutable";
    aio threads;
}

# Already-compressed images / fonts — no WebP negotiation
location ~* \.(gif|ico|webp|woff2|woff|ttf)$ {
    try_files $uri @404static;
    expires max;
    add_header Cache-Control "public, no-transform, immutable";
    aio threads;
}

try_files $uri$webp_suffix $uri does the heavy lifting:

  1. If the browser sent Accept: image/webp, nginx tries photo.jpg.webp first
  2. If that file doesn't exist (or the browser doesn't support WebP), nginx falls back to photo.jpg

The Vary: Accept header is critical and easy to overlook. It tells Cloudflare — and any intermediate cache — that the same URL can return different content depending on the Accept header. Without it, a browser that doesn't support WebP could receive a cached WebP response and fail to render the image entirely. The first visitor's Accept header would poison the cache for everyone else.

  • ← Previous
  • 1
  • 2
  • Next →
©2026 KBeezie | Disclaimer | Privacy Notice