The paths, hostnames, container names, and secret-file locations below are representative examples. They describe the architecture and its constraints, not this website's deployment topology.

1 · Problem

The page was fast; the images were not

Original JPEG and PNG files were being delivered almost unchanged. A phone rendering a 360-pixel-wide card downloaded the same multi-thousand-pixel source as a large desktop display. The server could produce HTML in tens of milliseconds and database access was not the bottleneck, yet the page still filled slowly after the document arrived.

The symptoms were concrete:

  • 320–600 pixel interface blocks downloaded full-size originals;
  • 500 KB–2.7 MB JPEG files had no WebP or AVIF alternatives;
  • mobile and desktop transferred nearly the same amount of image data;
  • sliders and galleries initiated many heavy requests at once;
  • previews and full-screen images used the same source;
  • legacy uploads could not be fixed without a bulk migration;
  • every new upload recreated the same problem.

A representative 952 KB JPEG became an approximately 30 KB WebP at 640 pixels wide. That reduction of more than 30 times made the correct optimization target unambiguous: image dimensions, encoding, loading priority, and cache behaviour.

Original example
952 KB
640 px WebP
≈ 30 KB
Reduction
> 30×

2 · Architecture

Use imgproxy as a processor, not as the permanent cache

The image processor runs as a separate service. PHP only constructs a valid signed URL. imgproxy reads an immutable local source and creates the requested representation. nginx owns the public endpoint and persistent disk cache. The browser chooses the most appropriate width and format from the markup.

This separation keeps JPEG, PNG, WebP, and AVIF decoding outside PHP-FPM. Image work cannot consume a PHP worker's memory or delay a controller response. The processor can be constrained, monitored, restarted, or upgraded independently.

3 · Deployment

Pin and isolate the processor

A production deployment pins the image version, publishes the service only on loopback, mounts the source tree read-only, and gives temporary work its own bounded tmpfs.

services:
  imgproxy:
    image: darthsim/imgproxy:v4.0.14
    container_name: example-imgproxy
    restart: unless-stopped
    env_file: /etc/example-imgproxy.env
    environment:
      IMGPROXY_BIND: ":8080"
      IMGPROXY_LOCAL_FILESYSTEM_ROOT: /server/html/example.com/public
      IMGPROXY_QUALITY: "82"
      IMGPROXY_MAX_SRC_FILE_SIZE: "52428800"
      IMGPROXY_MAX_SRC_RESOLUTION: "100"
      IMGPROXY_USE_ETAG: "true"
      IMGPROXY_ENABLE_WEBP_DETECTION: "false"
      IMGPROXY_ENABLE_AVIF_DETECTION: "false"
    ports:
      - "127.0.0.1:8088:8080"
    volumes:
      - /server/html/example.com/public:/server/html/example.com/public:ro
    read_only: true
    tmpfs:
      - /tmp:size=256m,mode=1777
    mem_limit: 1g
    cpus: 2.0

Why format auto-detection is disabled

The output format is encoded in the URL suffix: one URL always means AVIF and another always means WebP. nginx therefore does not need the browser's Accept header in its cache key, and a cached response can never change format depending on request headers.

4 · Signing and sources

Do not expose an unlimited transformation API

An unsigned endpoint allows a visitor to vary dimensions, quality, mode, and output format indefinitely. Every unique URL can consume CPU and create another cached file. imgproxy URLs should therefore be protected by a random hex-encoded key and salt stored outside the repository.

  1. Build the complete processing path after the signature segment.
  2. Prefix that path with the binary salt.
  3. Calculate HMAC-SHA256 with the binary key.
  4. Encode the result as URL-safe Base64 without padding.
  5. Place the signature before the processing path.
function base64Url(string $value): string
{
    return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}

$source = base64Url('local:///images/example.jpg');
$path = '/rs:fit:640:0:0/q:82/' . $source . '.webp';
$signature = base64Url(hash_hmac(
    'sha256',
    $salt . $path,
    $key,
    true,
));

$publicUrl = '/media/' . $signature . $path;

Restrict sources before signing

The helper converts only approved public paths to local imgproxy sources:

/images/example.jpg      → local:///images/example.jpg
/uploads/a/b/file.jpg    → local:///uploads/a/b/file.jpg

Unknown external hosts, empty paths, and parent-directory segments are rejected before a signature is created. This prevents SSRF, stops the service from becoming an open HTTP proxy, and keeps every read inside the read-only application volume.

User-controlled parameters also need bounds. A practical helper limits width and height to 2400 pixels, quality to 35–95, formats to AVIF, WebP, JPEG, and PNG, and resize modes to fit, fill, and fill-down. If configuration or validation fails, it returns the original URL.

5 · nginx

Cache generated variants at the public boundary

imgproxy performs transformations but nginx provides the durable shared cache. The trailing slash in proxy_pass removes the public /media/ prefix before forwarding the request.

proxy_cache_path /var/cache/nginx/example-imgproxy
    levels=1:2
    keys_zone=example_images:64m
    max_size=5g
    inactive=30d
    use_temp_path=off;

location ^~ /media/ {
    proxy_pass http://127.0.0.1:8088/;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Connection "";

    proxy_cache example_images;
    proxy_cache_key $scheme$host$request_uri;
    proxy_cache_lock on;
    proxy_cache_lock_timeout 20s;
    proxy_cache_background_update on;
    proxy_cache_use_stale
        error timeout updating
        http_500 http_502 http_503 http_504;
    proxy_cache_valid 200 30d;
    proxy_cache_valid 404 1m;

    add_header X-Image-Cache $upstream_cache_status always;
    add_header Cache-Control
        "public, max-age=2592000, immutable" always;
    access_log off;
}

First request: MISS

  1. The browser requests a signed variant.
  2. nginx locks the absent cache key.
  3. imgproxy verifies the signature.
  4. The local original is decoded and resized.
  5. AVIF or WebP is returned and written to disk.

Later request: HIT

  1. The browser requests the same URL.
  2. nginx finds the cached file.
  3. The response is returned without invoking imgproxy.

proxy_cache_lock prevents simultaneous visitors from generating the same missing representation in parallel. Stale delivery keeps an existing representation available during transient processor failures or restarts.

6 · Responsive markup

imgproxy saves no traffic until the browser gets choices

The template must publish a small, intentional set of widths and explain the expected layout width through sizes. The browser then considers viewport size and device-pixel density before selecting a resource.

<picture class="responsive-picture">
  <source
    type="image/avif"
    srcset="/media/.../image.avif 480w,
            /media/.../image.avif 760w,
            /media/.../image.avif 1200w"
    sizes="(max-width: 760px) 100vw, 50vw">
  <source
    type="image/webp"
    srcset="/media/.../image.webp 480w,
            /media/.../image.webp 760w,
            /media/.../image.webp 1200w"
    sizes="(max-width: 760px) 100vw, 50vw">
  <img
    src="/media/.../image.webp"
    loading="lazy"
    decoding="async"
    alt="Descriptive alternative text">
</picture>
UseWidth setTypical sizes value
Cards and small sliders320, 480, 640, 800(max-width: 760px) 85vw, 25vw
Content images480, 760, 1200(max-width: 760px) 100vw, 50vw
Wide banners480, 760, 1200, 1920100vw
Full-screen viewup to 1920loaded after interaction

Loading priority matters

Images below the first viewport use loading="lazy" decoding="async". The primary first-screen image should instead use loading="eager" fetchpriority="high" decoding="async". Lazy-loading the largest visible element can delay the very rendering the pipeline is intended to improve.

Preview and enlargement are different requests

A gallery preview can offer only 400–800 pixel variants. The full-size URL belongs on the interactive link and should not appear in an image src before the user opens it. This preserves access to the original without transferring it during normal page rendering.

<a href="/images/original-map.png" data-full-image>
  <picture>... 400w, ... 600w</picture>
</a>

7 · Existing and new files

Transform on demand; prewarm after upload

Existing media

Bulk conversion is unnecessary. It spends CPU on unused files, risks a disruptive migration, duplicates storage, and still does not prevent future uploads from recreating the problem. Existing originals can remain untouched:

first real request → generate variant → nginx caches it → later requests HIT

New uploads

After an image has been safely stored, a background job may request the standard width and format combinations to warm the cache. SVG files are excluded because they do not need raster transcoding. Warming stays outside the user-facing HTTP response and remains optional: a missed or delayed job still falls back to normal generation on first use.

$fileId = Files::add($fileFields);

if (
    str_starts_with((string) $fileFields['type'], 'image/')
    && $fileFields['type'] !== 'image/svg+xml'
) {
    Image::prewarm($publicUrl); // Dispatches background work.
}

return $fileId;

Account for the picture wrapper

Replacing img with picture adds a DOM element. Shared styles keep existing cover, contain, and sizing rules predictable:

.responsive-picture {
  display: block;
  width: 100%;
  height: 100%;
}

.responsive-picture > img {
  width: 100%;
  height: 100%;
}

8 · Rollout

Integrate in measured stages

  1. Measure HTML TTFB and database latency.
  2. Inventory local JPEG and PNG dimensions and file sizes.
  3. Identify the heaviest pages and request bursts.
  4. Deploy imgproxy on loopback with bounded resources.
  5. Generate and store signing secrets outside the repository.
  6. Verify one direct signed transformation.
  7. Add the nginx endpoint, disk cache, locking, and stale policy.
  8. Verify MISS followed by HIT for the same URL.
  9. Add a validated PHP URL helper and responsive picture helper.
  10. Convert the largest first-screen and content images first.
  11. Apply appropriate eager and lazy loading priorities.
  12. Separate gallery previews from enlargement sources.
  13. Add asynchronous prewarming for new raster uploads.
  14. Test desktop, tablet, mobile, and high-density displays.

9 · Result

The originals stay; routine delivery becomes adaptive

  • Original files remain unchanged and available for full-size use.
  • Legacy media is transformed lazily without a migration.
  • New raster uploads can prewarm only the standard variants.
  • Mobile clients no longer have to download desktop originals.
  • AVIF is preferred where supported and WebP provides the fallback.
  • Below-fold media loads lazily while first-screen media receives priority.
  • Preview and enlargement requests are separated.
  • Repeat requests are served from nginx without invoking imgproxy.
  • Transformation parameters are protected by HMAC signatures.
  • The processor cannot modify source files or accept public connections.

The measured example dropped from approximately 952 KB to 30 KB for a 640-pixel WebP. The exact ratio varies with image content and source quality, but the architectural gain remains the same: transfer size follows the display context instead of the stored original.

10 · Verification

Test the complete delivery path

docker compose ps
curl -fsS http://127.0.0.1:8088/health
nginx -t

curl -D headers.txt -o image.bin 'https://example.com/media/...avif'
file image.bin

curl -I 'https://example.com/media/...webp'
curl -I 'https://example.com/media/...webp'

The response must have the requested image/avif or image/webp content type. Repeating the same URL should change X-Image-Cache from MISS to HIT. Visual checks must cover desktop and mobile widths, high-density screens, first-screen loading, sliders, cover and contain modes, full-screen opening, and pages where optional images are absent.

Operational limits remain

  • The first request for an uncached variant costs more than a HIT.
  • AVIF usually takes longer to encode than WebP.
  • Changing an original at the same path does not change a signed URL.
  • immutable requires a new URL when image content changes.
  • Disk cache size and free space require monitoring.
  • Too many width sets create too many near-duplicate variants.
  • Signing-key rotation needs a transition strategy.
  • An unavailable processor affects new cache misses, so health checks and restart policy matter.
  • Upload prewarming must not run synchronously in the request that stores the file.

The result is not a faster HTML response. It is a much smaller and more deliberate media transfer: unchanged originals, adaptive browser choices, transformations generated only when needed, and repeat traffic served from nginx without invoking either PHP or imgproxy.