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.
- Build the complete processing path after the signature segment.
- Prefix that path with the binary salt.
- Calculate HMAC-SHA256 with the binary key.
- Encode the result as URL-safe Base64 without padding.
- 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
- The browser requests a signed variant.
- nginx locks the absent cache key.
- imgproxy verifies the signature.
- The local original is decoded and resized.
- AVIF or WebP is returned and written to disk.
Later request: HIT
- The browser requests the same URL.
- nginx finds the cached file.
- 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>
| Use | Width set | Typical sizes value |
|---|---|---|
| Cards and small sliders | 320, 480, 640, 800 | (max-width: 760px) 85vw, 25vw |
| Content images | 480, 760, 1200 | (max-width: 760px) 100vw, 50vw |
| Wide banners | 480, 760, 1200, 1920 | 100vw |
| Full-screen view | up to 1920 | loaded 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
- Measure HTML TTFB and database latency.
- Inventory local JPEG and PNG dimensions and file sizes.
- Identify the heaviest pages and request bursts.
- Deploy imgproxy on loopback with bounded resources.
- Generate and store signing secrets outside the repository.
- Verify one direct signed transformation.
- Add the nginx endpoint, disk cache, locking, and stale policy.
- Verify MISS followed by HIT for the same URL.
- Add a validated PHP URL helper and responsive picture helper.
- Convert the largest first-screen and content images first.
- Apply appropriate eager and lazy loading priorities.
- Separate gallery previews from enlargement sources.
- Add asynchronous prewarming for new raster uploads.
- 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.
immutablerequires 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.