Images play an important role in any web application. They can be interface decoration or the site's main content. Either way, developers and administrators need an efficient way to handle pictures, and this article looks at the solutions the Angie web server provides.
Video version
For convenience, a video version of this article is available on YouTube.
Adaptive image delivery
Modern web browsers support many formats that solve different problems. These formats can also be classified by client support:
- classic (full support) — JPEG, PNG, GIF;
- new (limited support) — WebP, AVIF.
New image compression formats appeared as a byproduct of the VP8 (WebP) and AV1 (AVIF) video codecs, while keeping high compression ratios. Using new image formats on a site means preserving compatibility with older clients — that is, adaptive image delivery. Clients that support the new format get WebP or AVIF; everyone else gets the classic formats.
Adaptive delivery can be implemented with HTML markup, where the browser picks the supported format itself. That approach requires changing page code and support from the web application.
The other option is to use the web server and the HTTP Accept header, which lists the supported formats. With this variant the page code stays unchanged, while clients receive different formats depending on what they declare in the Accept header. The new-format image versions must already be on disk; they differ by an addition to the file name. For example, next to 1.jpg we place 1.jpg.avif. That is the approach we implement in this part of the article.
The first part of the solution: detect format support from the Accept header. For that we use variable definition via the map directive:
http {
map $http_accept $webp_suffix {
"~*webp" ".webp";
}
map $http_accept $avif_suffix {
"~*avif" ".avif";
"~*webp" ".webp";
}
}
This part of the configuration creates the variables $webp_suffix and $avif_suffix. Their values are the extra file extensions we place next to the original images.
The second part of the configuration: a location for adaptive image delivery. It is worth isolating a dedicated block for exactly these image formats — a regular expression works well for that:
location ~* \.(jpe?g|gif|png)$ {
try_files $uri$avif_suffix $uri$webp_suffix $uri =404;
add_header Cache-Control "max-age=31536000, public, no-transform, immutable";
}
The example above isolates a location that serves files with the jpg, jpeg, gif and png extensions. The try_files directive does most of the work: it uses the variables $webp_suffix and $avif_suffix to extend the file name (.webp,.avif). If the file exists on disk, the new version is served; otherwise the regular one. The second directive (add_header) handles client-side caching of the resource, which is covered in more detail in a separate article on client caching. Let's demonstrate how this block works with pseudocode:
$ ls
1.jpg 1.jpg.avif 1.jpg.webp
# Client
>GET /1.jpg
>Accept: webp,avif,*/*
# Angie
< $webp_suffix = ‘.webp’;
< $avif_suffix = ‘.avif’;
< try_files 1.jpg.avif 1.jpg.webp 1.jpg =404;
< send 1.jpg.avif
The client receives the contents of 1.jpg.avif with a correct Content-Type header (as long as it is defined in /etc/angie/mime.types beforehand). From the HTML markup side nothing changes: the image tag can still look like <img src="1.jpg">.
Note that this scheme works well when the server talks to clients directly. If a caching layer (a CDN, for example) sits between Angie and the client, verify that the scheme behaves correctly — you may need to add a Vary: Accept header, which tells the caching server that the response depends on the request's Accept header.
So far we have only been serving pre-made images, but the Angie web server can process images itself. Let's start with image resizing.
Scaling, cropping and rotating images
Image handling in Angie is implemented in the Image Filter module. The module is built dynamically and ships as a separate package. Installation:
apt install angie-module-image-filter
Loading the module (in the main context):
load_module modules/ngx_http_image_filter_module.so;
The module relies on the libgd library, so format support is limited to what the installed library can do (with a package-based install). For example, on Ubuntu 24.04 the library supports WebP but not AVIF. The Image Filter module also has HEIC support, but browser support for it is so weak (Apple systems only) that it is useless for web development.
The first thing the module enables is on-the-fly image scaling. That way you don't have to pre-cut every required image version — one image version on disk is enough. Image conversion is of course an expensive CPU operation, so we will discuss caching the results later.
For dynamic image scaling it is enough to create a location for images with the image_filter directive and its related parameters:
server {
listen 80;
root /var/www/image;
location ~* ^/img/(?<width>\d+|-)x(?<height>\d+|-)/(?<file>.+\.jpe?g)$ {
alias /var/www/image/img/$file;
image_filter resize $width $height;
image_filter_jpeg_quality 80;
image_filter_interlace on;
image_filter_sharpen 20;
error_page 415 = /empty;
}
location = /empty {
empty_gif;
}
}
This example uses a location with a regular expression that saves URI parts into variables ($width, $height and $file). The block serves only JPEG files and scales by the width ($width) and height ($height) parameters, with compression quality set to 80 (image_filter_jpeg_quality). The configuration also requests a progressive version of the file (image_filter_interlace) and extra sharpening (image_filter_sharpen). So the server can find the picture on disk, the alias directive points at the source file. To handle the 415 (Unsupported Media Type) error, a location returning an empty single-pixel GIF is defined.
Test images must live in /var/www/image/img/. To get a scaled version, send requests like:
GET /img/210x210/1.jpg
GET /img/210x-/1.jpg
GET /img/-x210/1.jpg
The first request sets both width and height. In that case proportional scaling is used (as always) and the image is "fit into" the given rectangle. The second request sets only the width, the third only the height.
The maximum image size the module can work with is set by image_filter_buffer and defaults to 1 MB.
A similar configuration for lossless formats (PNG, GIF):
location ~ ^/img/(?<width>\d+)x(?<height>\d+|-)/(?<file>.+\.(png|gif))$ {
alias /var/www/image/img/$file;
image_filter resize $width $height;
image_filter_interlace on;
image_filter_sharpen 20;
error_page 415 = /empty;
}
Note that this configuration gives maximum flexibility in choosing image sizes, but it can become a DoS target: an attacker can request a large number of images with arbitrary dimensions. As protection, define locations with fixed size parameters and use caching.
Besides scaling, the module can crop images. The configuration is similar to the first one:
location ~ ^/img/(?<width>\d+|-)x(?<height>\d+|-)/(?<file>.+\.jpe?g)$ {
alias /var/www/image/img/$file;
image_filter crop $width $height;
image_filter_jpeg_quality 80;
image_filter_interlace on;
image_filter_sharpen 20;
error_page 415 = /empty;
}
The crop transformation scales the image along the longer side and then trims the excess using the second size parameter. For example, with a portrait image (1000×1500 px) and a requested size of 400×400, scaling to 400 by width comes first, then a crop to 400 pixels in height (the central part of the image remains).
The module can also rotate images by multiples of 90 degrees, using the rotate parameter of the image_filter directive. Rotation can be combined with scaling or cropping:
location ~ ^/img/(?<width>\d+|-)x(?<height>\d+|-)/(?<file>.+\.jpe?g)$ {
alias /var/www/image/img/$file;
image_filter resize $width $height;
image_filter rotate 90;
image_filter_jpeg_quality 80;
image_filter_interlace on;
image_filter_sharpen 20;
error_page 415 = /empty;
}
In the example above the 90-degree counterclockwise rotation happens after resizing, while with crop the rotation happens before resizing.
The module can also check the image format (the test action) and return picture size information (the size action).
So we can now resize, crop and rotate images — time to talk about optimization.
Conversion and optimization
At the beginning of the article we mentioned that the Image Filter module in Angie supports various formats (given matching libgd support): JPEG, WebP, AVIF, HEIC. Unlike the Nginx module, Angie adds the ability to convert images between formats. That opens up interesting options for building an image optimization system with the web server itself.
First scenario: convert images from old formats to new. The adaptive delivery techniques from the first part of the article come in handy here — first detect whether the client supports the needed format, and if it does, give it the optimized version.
Define prefixes for the new formats based on the request's Accept header:
http {
map $http_accept $webp_prefix {
"~*webp" "-webp";
}
map $http_accept $avif_prefix {
"~*avif" "-avif";
"~*webp" "-webp";
}
}
Now let's adjust the server structure in Angie a bit. One server handles the frontend, the other handles image processing. The frontend configuration:
server {
listen 443 ssl;
root /var/www/image;
rewrite ^/img(?<file>/.+)$ /img${webp_prefix}$file last;
location ~ ^/img.+/.+\.(jpe?g|png|gif)$ {
add_header Vary Accept;
expires max;
error_page 404 = @resize;
}
location @resize {
add_header Vary Accept;
expires max;
proxy_pass http://127.0.0.1:8081$uri;
}
}
This server serves images directly when they exist on disk, and otherwise proxies to another server (http://127.0.0.1:8081) for conversion. Note the rewrite directive: it redirects the request inside the server to the WebP version when the browser supports it.
Now the image processing server configuration:
server {
listen 8081;
root /var/www/image;
location ~ ^/img-webp/(?<file>.+)$ {
alias /var/www/image/img/$file;
image_filter convert webp;
image_filter_webp_quality 80;
error_page 415 = /empty;
}
}
Now a request for a JPEG image returns an image converted to WebP:
curl --head -H "Accept: image/webp" https://localhost/img/1.jpg
HTTP/2 200
server: Angie/1.11.4
...
content-type: image/webp
vary: Accept
A similar configuration for converting images to AVIF:
location ~ ^/img-avif/(?<width>\d+)x(?<height>\d+|-)/(?<file>.+)$ {
alias /var/www/image/img/$file;
image_filter convert avif;
image_filter resize $width $height;
image_filter_avif_quality 80 6;
error_page 415 = /empty;
}
Note that changing the format uses the convert mode of image_filter. Compression quality is set with the image_filter_avif_quality and image_filter_webp_quality directives.
Conversion can be combined with resizing, for example for WebP:
location ~ ^/img-webp/(?<width>\d+)x(?<height>\d+|-)/(?<file>.+)$ {
alias /var/www/image/img/$file;
image_filter convert webp;
image_filter resize $width $height;
image_filter_webp_quality 80;
image_filter_sharpen 20;
error_page 415 = /empty;
}
Image processing and conversion put a significant load on the server's CPU, so the next logical step in our system is caching the results to reduce server load.
Caching results
There are two ways to store the results of Image Filter processing: classic server-side proxy_cache caching and saving results to disk with proxy_store. Let's start with the second method.
To use proxy_store, modify the frontend part of the configuration — specifically the proxying location:
location @resize {
add_header Vary Accept;
expires max;
proxy_pass http://127.0.0.1:8081$uri;
proxy_store /var/www/image$uri;
}
Now, when a request for conversion to WebP comes in:
curl --head -H "Accept: image/webp" https://localhost/img/1.jpg
the conversion result is stored as a file at /var/www/image/img‑webp/1.jpg. If you request a resize:
curl --head -H "Accept: image/webp" https://localhost/img/100x100/1.jpg
the resulting file is saved along this path: /var/www/image/img-webp/100x100/1.jpg. Repeat requests to these image versions are handled as static, with no extra CPU cost.
So we solved the problem of regenerating versions and reduced the load. But what happens if the stored versions are no longer needed? With this approach, only manual cleanup — you maintain the image storage yourself.
The second approach makes cache parameters for the versions configurable and removes the need for manual management. Here we use server-side caching. First, define the shared cache parameters (zone, key and lifetime):
http {
proxy_cache_path /var/local/angie/cache levels=1:2 inactive=30d keys_zone=one:10m:file=/etc/angie/cache.state max_size=1G;
}
We created a cache zone named one, set the shared memory size (10m) and the maximum disk size (max_size=1G). A state file (file) was added to speed up loading the cache status.
Now update the proxying location configuration:
location @resize {
add_header Vary Accept;
expires max;
proxy_pass http://127.0.0.1:8081$uri;
proxy_cache one;
proxy_cache_key "$host$uri";
proxy_cache_lock on;
proxy_cache_min_uses 2;
proxy_ignore_headers "Cache-Control" "Expires";
proxy_cache_use_stale updating error timeout invalid_header http_500 http_502 http_504;
proxy_cache_background_update on;
proxy_cache_valid 200 14d;
proxy_cache_valid any 1m;
}
The new configuration enables the cache in the one zone and defines the cache key. Results are cached after two requests, so rare image variants don't clutter the disk (proxy_cache_min_uses). We also allowed stale image versions while the cache is updating or when the server has problems. Cache validity is capped at 14 days. Error responses get cached too. This configuration is only an example of the approach — pick concrete values based on the application's requirements.
We now have two ways to store image transformation results; use whichever fits you better.
Takeaways
Let's sum up image handling. The Angie web server has broad capabilities for working with images. The adaptive delivery method uses only the variables mechanism and the try_files directive, leaving all image work out of sight. The Image Filter module instead takes resizing and format conversion on itself, removing the need to pre-process pictures. The choice of approach is the reader's: either detailed optimization and minimal server load with pre-processed images, or dynamic image slicing without complicating the web application.