immerse: NGINX modern image format filter module
Debian/Ubuntu installation
These docs apply to the APT package nginx-module-immerse provided by the GetPageSpeed Extras repository.
- Configure the APT repository as described in APT repository setup.
- Install the module:
sudo apt-get update
sudo apt-get install nginx-module-immerse
Show suites and architectures
| Distro | Suite | Component | Architectures |
|----------|-------------------|-------------|-----------------|
| debian | bookworm | main | amd64, arm64 |
| debian | bookworm-mainline | main | amd64, arm64 |
| debian | trixie | main | amd64, arm64 |
| debian | trixie-mainline | main | amd64, arm64 |
| ubuntu | jammy | main | amd64, arm64 |
| ubuntu | jammy-mainline | main | amd64, arm64 |
| ubuntu | noble | main | amd64, arm64 |
| ubuntu | noble-mainline | main | amd64, arm64 |
NGINX filter module for transparent modern image format delivery. Intercepts
image responses from any source (static files, proxy_pass, FastCGI, etc.)
and converts them to WebP or AVIF based on client Accept headers. No URL
rewriting, no separate service, no application changes.
How It Works
ngx_immerse inserts into the NGINX filter chain. When a response with
Content-Type: image/jpeg, image/png, or image/gif passes through, the
module checks the client's Accept header for modern format support. If a
match is found, it either serves a cached conversion or triggers one via a
thread pool - keeping worker processes non-blocking.
Client Request NGINX
| |
|--- GET /photo.jpg ---------> |
| Accept: image/avif, |
| image/webp |
| |--- upstream / static file
| |<-- image/jpeg response
| |
| [ngx_immerse]
| |--- cache hit? serve cached avif
| |--- cache miss + lazy? serve jpeg,
| | queue background conversion
| |--- cache miss + sync? convert in
| | thread pool, serve avif
| |
|<-- 200 image/avif ---------- |
| Vary: Accept |
Features
- Transparent format negotiation - parses
Acceptheader per RFC 7231 with quality factor support (q=0rejects, highestqwins) - WebP and AVIF output - configurable quality, priority order, and conditional compilation (build with only one if desired)
- File-based cache - MD5-keyed with source mtime in the key, so cache invalidates automatically when the original image changes
- Two conversion modes -
lazy(serve original now, convert in background) andsync(convert inline, serve modern format immediately) - Thread pool integration - conversions run in NGINX thread pools, keeping the event loop free
- Graceful fallback - conversion failure, corrupt images, missing thread pool, full disk: always serves the original, never returns 500
- Size thresholds - skip images below
immerse_min_sizeor aboveimmerse_max_sizeto avoid wasting CPU on tiny icons or huge assets - CDN-safe - adds
Vary: Acceptso caches and CDNs don't serve the wrong format to the wrong client - Debug header -
X-Immerse: hit|miss|error|passshows what happened (toggleable) - Magic byte detection - identifies input format by file signature, not URL extension
Configuration
Minimal example
thread_pool immerse threads=4;
http {
immerse_cache_path /var/cache/nginx/immerse levels=1:2 max_size=1g;
server {
listen 80;
location /images/ {
immerse on;
immerse_thread_pool immerse;
alias /var/www/images/;
}
}
}
With proxied content
thread_pool immerse threads=4;
http {
immerse_cache_path /var/cache/nginx/immerse levels=1:2 max_size=1g;
server {
listen 80;
location /api/photos/ {
immerse on;
immerse_mode sync;
immerse_thread_pool immerse;
proxy_pass http://backend;
}
}
}
Full example with all directives
thread_pool immerse threads=4;
http {
immerse_cache_path /var/cache/nginx/immerse levels=1:2
max_size=2g inactive=60d;
# Defaults for all locations
immerse_formats avif webp;
immerse_webp_quality 82;
immerse_avif_quality 63;
server {
listen 80;
# Static images - lazy mode (default)
location /images/ {
immerse on;
immerse_thread_pool immerse;
immerse_min_size 2k;
immerse_max_size 5m;
alias /var/www/images/;
}
# Proxied images - sync mode for immediate conversion
location /api/photos/ {
immerse on;
immerse_mode sync;
immerse_thread_pool immerse;
proxy_pass http://backend;
}
# WebP only (no AVIF)
location /thumbnails/ {
immerse on;
immerse_formats webp;
immerse_webp_quality 75;
immerse_thread_pool immerse;
alias /var/www/thumbs/;
}
# Disable debug header in production
location /cdn/ {
immerse on;
immerse_x_header off;
immerse_thread_pool immerse;
alias /var/www/cdn/;
}
}
}
Directives Reference
immerse
Syntax: immerse on | off;
Default: off
Context: location
Enables or disables image format conversion for the location. When enabled, the module intercepts image responses and attempts conversion based on client support.
Requires immerse_cache_path to be set at the http level. If the cache
path is not configured, the module logs an error and passes the response
through unchanged.
immerse_cache_path
Syntax: immerse_cache_path path [levels=levels] [max_size=size] [inactive=time];
Default: none (required when immerse is enabled)
Context: http
Sets the cache directory and parameters. This directive is required - the module will not convert images without a configured cache path.
Parameters:
- path - filesystem directory for cached conversions. Created automatically if it does not exist.
- levels - subdirectory hierarchy depth, specified as colon-separated
digits (1 or 2). Default:
1:2. Withlevels=1:2, a cache keya3b1c4d5e6...is stored atpath/a/3b/a3b1c4d5e6....webp. - max_size - maximum total cache size. Accepts size suffixes (
k,m,g). Default: unset (no limit). - inactive - time after which unused cached files are eligible for
removal. Accepts time suffixes (
s,m,h,d). Default:30d.
immerse_cache_path /var/cache/nginx/immerse levels=1:2 max_size=1g inactive=30d;
immerse_formats
Syntax: immerse_formats format ...;
Default: avif webp
Context: http, server, location
Sets the preferred output formats in priority order. When multiple formats are accepted by the client with equal quality factors, the first format listed here wins.
Valid formats: avif, webp. At least one must be supported at compile time.
## Prefer WebP over AVIF
immerse_formats webp avif;
## WebP only
immerse_formats webp;
immerse_mode
Syntax: immerse_mode lazy | sync;
Default: lazy
Context: location
Sets the conversion strategy for cache misses.
lazy - serves the original image immediately with no latency overhead. If the image source is file-backed, queues a background conversion in the thread pool. The converted variant is available for subsequent requests. Best for static file serving where first-request latency matters.
sync - buffers the entire response body, converts it in a thread pool, and serves the converted image in the same request. The worker is not blocked (the thread pool handles the work). Best for proxied content or when you want every response to be in a modern format.
## Static files - lazy is fine, cache warms quickly
location /images/ {
immerse on;
immerse_mode lazy;
}
## API responses - sync ensures modern format on first request
location /api/photos/ {
immerse on;
immerse_mode sync;
proxy_pass http://backend;
}
immerse_webp_quality
Syntax: immerse_webp_quality quality;
Default: 80
Context: http, server, location
WebP encoding quality (1-100). Higher values produce better visual quality at larger file sizes. Values around 75-85 provide a good balance for most content.
immerse_avif_quality
Syntax: immerse_avif_quality quality;
Default: 60
Context: http, server, location
AVIF encoding quality (1-100). AVIF achieves good visual quality at lower numeric values than WebP or JPEG. Values around 50-70 are typical for web delivery. The encoder uses speed 6 (balanced speed/quality).
immerse_min_size
Syntax: immerse_min_size size;
Default: 1k (1024 bytes)
Context: http, server, location
Minimum response body size for conversion. Images smaller than this are passed through unchanged. This avoids wasting CPU on tiny images (favicons, 1x1 tracking pixels) where format conversion provides negligible savings.
immerse_max_size
Syntax: immerse_max_size size;
Default: 10m (10485760 bytes)
Context: http, server, location
Maximum response body size for conversion. Images larger than this are passed through unchanged. This prevents resource exhaustion from very large images that would consume significant memory and CPU during decoding/encoding.
immerse_thread_pool
Syntax: immerse_thread_pool name;
Default: default
Context: http, server, location
Name of the NGINX thread pool to use for conversion tasks. Must match a
thread_pool directive in the main configuration context.
## Define a dedicated pool
thread_pool immerse threads=4;
http {
server {
location /images/ {
immerse on;
immerse_thread_pool immerse;
}
}
}
Sizing guidance: start with the number of CPU cores. Image encoding is CPU-bound, so more threads than cores provides no benefit. If the same server handles other thread pool work (aio), consider a dedicated pool for immerse.
immerse_x_header
Syntax: immerse_x_header on | off;
Default: on
Context: http, server, location
Controls the X-Immerse response header. When enabled, every processed
response includes a header indicating what happened:
| Value | Meaning |
|---|---|
hit |
Served from cache |
miss |
Cache miss; converted (sync) or original served (lazy) |
error |
Conversion failed; original served as fallback |
Disable this in production if you don't want to expose internal module state to clients.
Response Headers
When ngx_immerse processes a response, it modifies or adds the following headers:
| Header | Value | When |
|---|---|---|
Content-Type |
image/webp or image/avif |
Converted or served from cache |
Content-Length |
Size of converted image | Converted or served from cache |
Vary |
Accept |
Always (even on pass-through) when module is active |
X-Immerse |
hit, miss, or error |
When immerse_x_header is on |
The Vary: Accept header is critical for correct CDN behavior. Without it,
a CDN might cache a WebP response and serve it to a client that only supports
JPEG.
Accept Header Parsing
The module parses the Accept request header per RFC 7231:
- Extracts
image/webpandimage/avifentries with their quality factors q=0means the client explicitly rejects that formatq=1(or noqparameter) means full support- The format with the highest
qvalue is selected - On equal
q, the first format inimmerse_formatswins - If neither format is present or both have
q=0, the original is served
Examples:
| Accept header | Result (with default immerse_formats avif webp) |
|---|---|
image/avif, image/webp |
AVIF (first in config, equal q) |
image/webp |
WebP |
image/avif;q=0.8, image/webp;q=0.9 |
WebP (higher q) |
image/avif;q=0, image/webp |
WebP (AVIF rejected) |
text/html, image/jpeg |
Original (no modern format) |
Input Format Detection
Source images are identified by magic bytes in the response body, not by file extension:
| Format | Magic bytes |
|---|---|
| JPEG | FF D8 FF |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| GIF | GIF87a or GIF89a |
Images already in WebP or AVIF format are passed through unchanged. Animated GIFs (multiple frames) are also passed through.
Cache
How it works
The cache stores converted images in a directory hierarchy keyed by MD5 hash.
The hash input is URI + source_mtime + target_format + quality, so:
- Different formats (WebP, AVIF) get separate cache entries
- Changing quality settings produces new cache entries
- Modifying the original image (changing its mtime) automatically invalidates the cached conversion
Directory layout
With levels=1:2, a cache key a3b1c4d5... produces:
/var/cache/nginx/immerse/a/3b/a3b1c4d5e6f7890123456789abcdef01.webp
Atomic writes
Cache files are written atomically: data goes to a temporary file first, then
rename() moves it into place. This prevents serving partially-written files
under concurrent load.
Cache warming
In lazy mode, the first request for an image serves the original. The
conversion runs in the background, and subsequent requests get the cached
modern format. In sync mode, the very first request triggers conversion and
serves the result.
Manual cache clearing
To clear the entire cache:
rm -rf /var/cache/nginx/immerse/*
No NGINX reload is required. The module will recreate directories as needed.
Error Handling
ngx_immerse follows a strict "never break what already works" policy:
| Condition | Behavior |
|---|---|
| Conversion fails (codec error) | Serve original, log error |
| Cache write fails (disk full, permissions) | Serve converted from memory, log warning |
| Corrupt or truncated input image | Serve original, log error |
Image below min_size or above max_size |
Pass through unchanged |
No modern format in client Accept |
Pass through unchanged, add Vary: Accept |
| Thread pool not found | Fall back to synchronous (blocking) conversion |
immerse_cache_path not configured |
Pass through unchanged, log error |
| Unknown image format (not JPEG/PNG/GIF) | Pass through unchanged |
The module will never return a 500 error due to a conversion failure.
Architecture
Source files
| File | Purpose |
|---|---|
config |
NGINX build system integration, library detection |
src/ngx_http_immerse_common.h |
Shared types, constants, function declarations |
src/ngx_http_immerse_module.c |
Module entry point, directives, configuration lifecycle |
src/ngx_http_immerse_filter.c |
Header and body filter chain, state machine, thread pool dispatch |
src/ngx_http_immerse_convert.c |
Image decode/encode engine (runs in thread pool) |
src/ngx_http_immerse_cache.c |
Cache key generation, lookup, atomic store |
src/ngx_http_immerse_accept.c |
RFC 7231 Accept header parser |
src/ngx_http_immerse_util.c |
Response sending, body buffering, format detection, header helpers |
Thread safety
All image conversion work runs in NGINX thread pool workers. The conversion
code (ngx_http_immerse_convert.c) uses only malloc/free, POSIX file I/O,
and image library calls. It never accesses NGINX shared state, request pools,
or the event loop.
Results are passed back to the main event loop via the standard NGINX thread
task completion mechanism (ngx_thread_task_t).
State machine
The body filter uses a phase-based state machine:
START -> READ -> CONVERT -> SEND -> DONE (sync mode)
PASS -> DONE (lazy mode, first request)
SERVE_CACHE -> DONE (cache hit)
Testing
Docker-based (recommended)
## Run all tests (HUP mode for ~10x faster iteration)
make tests
## Run a specific test file
make tests T=t/sync.t
## Run without HUP mode (cleaner state between tests)
make tests HUP=0
## Interactive shell for debugging
make shell
## Rebuild base image (after Dockerfile changes)
make base-image
## Test against a different NGINX version
make tests NGINX_VERSION=release-1.26.2
CI
GitHub Actions runs tests against NGINX 1.26.2, 1.27.3, and 1.28.0 on every push and pull request.
Test suite
| File | Coverage |
|---|---|
t/accept.t |
Accept header parsing, q-values, format selection |
t/sync.t |
Sync mode conversion for JPEG, PNG, GIF to WebP/AVIF |
t/lazy.t |
Lazy mode: original served first, cache populated after |
t/cache.t |
Cache hit/miss behavior |
t/limits.t |
min_size and max_size filtering |
t/fallback.t |
Corrupt image fallback |
t/passthrough.t |
Module disabled, non-image content, no Accept header |
t/config.t |
Directive validation, x_header toggle |
t/vary.t |
Vary: Accept header presence |
Debugging
- Check
test-error.login the repo root for NGINX debug output - Use
make shellto enter the container and run tests manually - Log level is set to
debugin the Docker test environment