Sign inSign up

smeagolworms4/image-resizer

By smeagolworms4

•Updated about 2 months ago

On-the-fly image resizing and format conversion over HTTP, to sit behind a CDN or caching proxy

Image
0

2.1K

smeagolworms4/image-resizer repository overview

⁠image-resizer

"Buy Me A Coffee" "Buy Me A Coffee"

Read this in French⁠.

Resizes and converts your images on the fly, over HTTP. Point it at a folder or at any HTTP storage, and every variant becomes an address: /photos/beach.jpg/_cover_320_320_80.webp. Built to sit behind a cache — CloudFront, Varnish, nginx, Cloudflare — which is what turns it into a fast service: it only ever computes a given variant once.

Docker Pulls Image Size arch

Why it exists and how it is used in production: Image Resizer : la taille sur demande⁠.

⁠What it does

  • Resizes, crops and converts to JPEG, PNG, WebP or AVIF, with the five sharp fitting modes (cover, contain, fill, inside, outside).
  • Reads from as many sources as you like: local folders, HTTP storage (S3, Vercel Blob, a plain web server), or a mix — one name per source, and that name opens the URL.
  • Caches originals on disk so the upstream is hit once, whatever the number of variants.
  • Decodes iPhone photos (HEIC/HEVC), which sharp cannot open on its own.
  • Applies EXIF orientation and strips metadata (including GPS) from what it serves.
  • Protects itself: dimensions are clamped, oversized originals refused, and beyond a configurable number of simultaneous transformations it answers 503 instead of falling over.
  • Signs its URLs, if you want it to: with a key set, an address is only served if it carries the right HMAC — nobody gets to order the variants of their choosing.
  • Everything is configured through environment variables — no config file, nothing to rebuild.
  • Runs on amd64, arm64 and armv7: a NAS, a VPS or a Raspberry Pi all work.

⁠The URL format

/<source>/<path/to/file>/<preset>

The preset is the last path segment, and it fully describes the transformation:

_<fit>_<width>_<height>_<quality>.<format>
URLResult
/photos/beach.jpg/_.webpWebP, default quality, downscaled to MAX_SIZE if needed
/photos/beach.jpg/_cover_320_320_80.webp320×320 WebP, cropped to fill, quality 80
/photos/beach.jpg/_inside_1200__.jpg1200 px wide, height proportional, default quality
/photos/holiday/2024/beach.jpg/_contain_600_400_90.pngworks at any folder depth
/photos/beach.jpg/_original___.jpgthe original file, byte for byte

Empty fields fall back to the defaults, which is why _original___.jpg carries three underscores. Unknown fitting modes and disallowed formats are rejected with a 400 rather than silently served as something else — a cache would keep that mistake for weeks.

A preset can also carry a signature, which is optional and off by default — see SIGNATURE.md⁠:

/photos/beach.jpg/_cover_320_320_80_3dc222d73386b95c.webp

Why a path segment and not a query string: caches key on the URL, and many of them are configured to drop or reorder query strings, which silently multiplies or merges entries. As a path, each variant is one immutable address — and since the extension comes last, browsers, curl -O and download dialogs all see a real .webp.

⁠Getting started

Nothing to clone, nothing to build: the image is published on Docker Hub⁠. Create an empty folder and put this docker-compose.yml in it:

services:
  image-resizer:
    image: smeagolworms4/image-resizer:latest
    container_name: image-resizer
    restart: unless-stopped
    stop_grace_period: 30s
    user: "${PUID:-1000}:${PGID:-1000}"
    environment:
      SOURCE_DEMO: /app/public
      #SOURCE_PHOTOS: /photos
      #SOURCE_CDN: https://storage.example.com
    ports:
      - "${PORT_HOST:-3000}:3000"
    volumes:
      - ./cache:/cache
      # - /path/to/your/photos:/photos:ro

Then, next to it:

mkdir cache
docker compose up -d

No configuration file anywhere: every setting is an environment variable, declared wherever suits you — environment: above, an env_file:, -e flags, or your orchestrator's own mechanism. .env.example lists them all, and can be used as an env_file if you prefer to keep them in one place.

At least one source is required, though: the service refuses to start without one, rather than starting and 400-ing on everything.

The image ships a few sample images, so the installation can be checked straight away:

http://localhost:3000/demo/test.png/_cover_320_240_80.webp
http://localhost:3000/health
⁠The single-command equivalent
docker run -d \
  --name image-resizer \
  --restart unless-stopped \
  --user 1000:1000 \
  -p 3000:3000 \
  -e SOURCE_PHOTOS=/photos \
  -v "$(pwd)/cache:/cache" \
  -v /path/to/your/photos:/photos:ro \
  smeagolworms4/image-resizer:latest
⁠Without Docker
npm install
SOURCE_PHOTOS=/path/to/photos CACHE_DIR=./cache npm start

Node 20.6 or later. heif-convert is only needed for HEIC photos, and the Docker image already has it — HEIC.md⁠ lists the package to install elsewhere.

⁠On AWS Lambda

The same service also ships as a zip ready to drop onto a Lambda function, one archive per architecture on every release⁠. Same variables, same URL format, same signing. LAMBDA.md⁠ covers it.

⁠Sources

A source is a name, which becomes the first segment of the URL, and a target:

SOURCE_PHOTOS=/data/photos                 # local folder
SOURCE_MEDIA=file:///mnt/nas/media         # same thing, explicit
SOURCE_CDN=https://storage.example.com     # HTTP storage

Or all of them in one variable, which suits managed deployments better:

SOURCES='{"photos":"/data/photos","cdn":"https://storage.example.com"}'
SOURCES='photos=/data/photos,cdn=https://storage.example.com'

Local sources are read-only, and the path is checked against the source root: a ../ in the URL is rejected, never resolved.

HTTP sources are downloaded once. The original lands in CACHE_DIR/originals/<source>/, and every later variant is computed from that copy — so ten formats of the same photo cost one upstream request, not ten. Set CACHE_ORIGINALS=false to always go back to the source, or leave CACHE_DIR empty to disable disk writes entirely.

⁠Behind a cache

This service computes; it is not meant to be the thing your visitors hit. Put a cache in front, and each variant is computed once for its whole lifetime.

The responses carry what a cache needs: an ETag, and a Cache-Control with a long s-maxage (shared caches) alongside a shorter max-age (browsers) and stale-while-revalidate.

Cache-Control: public, max-age=604800, s-maxage=5184000, stale-while-revalidate=604800

CloudFront — origin the service, cache policy CachingOptimized, and forward no query string (they play no part here). Origin Shield is worth enabling: it collapses the requests from every edge location into a single one when a variant is cold.

Varnish — nothing special to write, the default builtin.vcl already does the right thing. Just give it room and let the long s-maxage do the talking:

sub vcl_backend_response {
    set beresp.grace = 24h;
}

nginx — a full cache in a handful of lines:

proxy_cache_path /var/cache/nginx/images levels=1:2 keys_zone=images:50m
                 max_size=20g inactive=90d use_temp_path=off;

location / {
    proxy_pass http://image-resizer:3000;
    proxy_cache images;
    proxy_cache_valid 200 90d;
    proxy_cache_valid 404 1m;
    # One upstream request for a cold variant, even under a burst.
    proxy_cache_lock on;
    proxy_cache_use_stale updating error timeout;
    add_header X-Cache-Status $upstream_cache_status;
}

If the service does not live at the root of its domain, BASE_PATH=/images shifts every route, health check included.

⁠Configuration

Everything is set through environment variables, and nothing else. They are all declared in the image with their default value, so the settings can be listed without opening the documentation:

docker run --rm smeagolworms4/image-resizer:latest env

An empty value is not a missing setting: it hands the decision back to the code (no source configured, computed header, number of cores). .env.example lists the same variables with comments, and a test checks that the three lists never drift apart.

⁠Sources and network
VariableDefaultPurpose
SOURCE_<NAME>—One source per variable. The name becomes the first URL segment
SOURCES—All sources at once, as JSON or name=target,name2=target2
PORT / HOST3000 / 0.0.0.0Listening socket
BASE_PATHemptyMount prefix, e.g. /images
HEALTH_PATH/healthHealth probe — never logged, never cached
TRUST_PROXYemptyExpress trust proxy: true, a hop count, or a list of IPs
⁠Cache and transformation
VariableDefaultPurpose
CACHE_DIR/cache in the imageWhere downloaded originals and expensive conversions are kept. Empty disables disk writes
CACHE_ORIGINALStrueKeep originals downloaded over HTTP
MIN_SIZE / MAX_SIZE1 / 2048Bounds applied to requested dimensions
MIN_QUALITY / DEFAULT_QUALITY10 / 80Bounds and default for quality
DEFAULT_FITcoverFitting mode when the preset leaves it out
DEFAULT_FORMATjpegOnly used as a fallback; the URL always states the format
ALLOWED_FORMATSjpeg,png,webp,avifAnything else is a 400. gif and tiff are available
ALLOW_ORIGINALtrueAllows _original___.ext, which serves the file untouched
AUTO_DOWNSCALEtrueWith no dimension requested, still bound the image to MAX_SIZE
STRIP_METADATAtrueRemove EXIF, GPS and colour profiles from the output
⁠Cache headers
VariableDefaultPurpose
MAX_AGE604800max-age: browsers (7 days)
S_MAX_AGE5184000s-maxage: shared caches (60 days)
STALE_WHILE_REVALIDATE604800Serve stale while refreshing
ERROR_MAX_AGE60How long error responses are cached
CACHE_CONTROL—Replaces the header computed from the four above
CORS_ORIGIN*Empty removes the CORS headers entirely
⁠Signed URLs
VariableDefaultPurpose
SIGNATURE_KEYemptyThe HMAC secret. Empty disables signing, and every URL is served as before
SIGNATURE_ALGORITHMsha256sha256, sha1 or sha512
SIGNATURE_LENGTH16Hex characters kept, 8 to 128 — within the digest's own length
⁠Decoding and resizing
VariableDefaultPurpose
AUTO_ROTATEtrueApply EXIF orientation — without it, phone photos come out lying down
ALLOW_ENLARGEMENTtrueAllow upscaling past the original's size
DEFAULT_POSITIONcenterArea kept by cover/contain: top, left top, … or entropy / attention, which pick the busiest area
RESIZE_KERNELlanczos3nearest, linear, cubic, mitchell, lanczos2, lanczos3, mks2013, mks2021
CONTAIN_BACKGROUND#000000Colour of the bars contain adds. #00000000 for transparent
FAIL_ONnoneDecoding strictness: none, truncated, error, warning
⁠Encoding
VariableDefaultPurpose
JPEG_MOZJPEGtrue~10 % smaller at the same quality, slightly slower. Implies progressive scans
JPEG_PROGRESSIVEfalseProgressive JPEG (already the case with mozjpeg)
JPEG_CHROMA_SUBSAMPLING4:2:04:4:4 keeps the colour detail, at the cost of size
PNG_COMPRESSION_LEVEL90 to 9
PNG_PALETTEfalseQuantise to a palette: far lighter, at the cost of gradients
WEBP_EFFORT40 to 6 — higher is smaller and slower
WEBP_LOSSLESSfalseLossless WebP
WEBP_SMART_SUBSAMPLEfalseReduces colour bleeding on sharp edges
AVIF_EFFORT40 to 9 — higher is smaller and much slower
AVIF_LOSSLESSfalseLossless AVIF
AVIF_CHROMA_SUBSAMPLING4:4:4Same trade-off as JPEG
⁠Upstream, load and logs
VariableDefaultPurpose
FETCH_TIMEOUT15000Timeout on the upstream request, in ms
FETCH_USER_AGENTimage-resizerUser-Agent used upstream
FETCH_HEADERS—JSON object of headers added upstream — where a private storage's authentication goes
FETCH_REDIRECTfollowfollow, error, manual
MAX_INPUT_BYTES67108864Above this, the original is refused (413)
MAX_CONCURRENCYnumber of coresSimultaneous transformations before answering 503
RETRY_AFTER2Retry-After sent with a 503, in seconds
SHARP_CONCURRENCY / SHARP_CACHE_MEMORY0 / 50sharp internals: threads (0 = automatic) and cache in MiB
LOG_FORMATtinymorgan format, or off
LOG_LEVELinfodebug, info, warn, error, silent
SHUTDOWN_TIMEOUT10000Grace given to in-flight transformations after a SIGTERM, in ms
⁠HEIC and video
VariableDefaultPurpose
HEIC_ENABLEDtrueHEIC/HEVC decoding. Disabled, those files get a 415
HEIC_COMMANDheif-convertThe converter binary
HEIC_MAX_CONCURRENCY2Simultaneous conversions — this one is expensive
HEIC_TIMEOUT30000Timeout, in ms
VIDEO_POSTER_ENABLEDfalsePoster frames — see below
VIDEO_POSTER_COMMANDffmpegThe extraction binary
VIDEO_POSTER_EXTENSIONSmp4,mov,webm,m4v,mkv,aviExtensions treated as video
VIDEO_POSTER_SEEK1Timestamp of the extracted frame, in seconds
VIDEO_POSTER_WIDTH1280Width of the extracted frame
VIDEO_POSTER_TIMEOUT30000Timeout, in ms

⁠Signed URLs

Off by default, and worth turning on the day the service faces the open internet: the dimensions live in the URL, so anyone reading the HTML can ask for _cover_1999_1999_100.avif and every variation of it — each one a cache miss and a full decode-resize-encode for an image no page will ever show. Set a key, and an address is only served if it carries the matching HMAC, rejected before any file is read.

→ How signing works, and how to produce a signature in JS, PHP, Python, .NET and Java⁠ — with reference values to check an implementation against, and the details that trip people up (encoding, BASE_PATH, key rotation).

⁠iPhone photos (HEIC / HEVC)

sharp's prebuilt binaries read the HEIC header but cannot decode HEVC, so the service sniffs the container itself and hands those files to heif-convert, which the image already contains. The conversion is expensive, capped at HEIC_MAX_CONCURRENCY runs and cached on disk — a burst on the same photo triggers one conversion, not fifty.

→ Why an external binary, installing the decoder, settings and troubleshooting⁠

⁠Video poster frames

Off by default. Once VIDEO_POSTER_ENABLED=true is set, appending an image extension to a video name extracts a frame from it:

/media/holiday/clip.mp4.jpg/_cover_640_360_80.webp

The frame goes through the same pipeline, and the same cache, as any other image. It needs ffmpeg, which the published image does not carry — it would add several hundred megabytes for an optional feature. Rebuild it with:

services:
  image-resizer:
    build:
      context: https://github.com/Smeagolworms4/image-resizer.git
      args:
        INSTALL_FFMPEG: "true"

⁠Holding up under load

sharp is greedy, and a burst of large originals is enough to bring a machine to its knees. Rather than queue up work nobody is waiting for any more, the service refuses: past MAX_CONCURRENCY simultaneous transformations, it answers 503 with a Retry-After. A cache in front retries, and the service stays up.

Two other bounds matter: MAX_SIZE, which caps requested dimensions — nobody gets to ask for 30000 px — and MAX_INPUT_BYTES, which refuses an oversized original before decoding it.

⁠Tests

npm test

77 tests, no network access needed: fixtures come from public/, and a fake upstream HTTP server is started on the fly. They cover every fitting mode and output format, dimension and quality clamping, automatic downscaling, byte-for-byte originals, path traversal, percent-encoded names, BASE_PATH, cache and CORS headers, 304 revalidation, upstream error translation, disk caching (proven by counting upstream requests), and load shedding.

URL signing gets its own file: that an empty key changes nothing, that a signature only opens the variant it was computed for — a stolen one does not buy a 2000×2000 — and that neither BASE_PATH nor percent-encoding takes part in the computation.

The HEIC tests run against a real HEVC file committed with the sample images (public/photo.heic), and check, among other things, that sharp still cannot decode it — the day that test fails, the external converter can go. The file is committed rather than generated because the libheif shipped by Debian and Ubuntu carries no x265 encoder: it reads HEIC, it cannot write it.

The Lambda entry point has its own file: event shapes, base64, and the 304 that a relay through fetch silently loses.

The image itself is tested too:

docker build -t image-resizer:test .
test/docker-smoke.sh image-resizer:test

It checks that the container starts, runs as a non-root user, ships heif-convert, writes to its cache volume, and returns images of the right dimensions. It runs on every push through GitHub Actions, on Node 20, 22 and 24 — and on the three published architectures, the two ARM ones under QEMU.

⁠Architecture

src/
  index.js        entry point: configuration, listening socket, clean shutdown
  lambda.js       AWS Lambda entry point: event → HTTP request, response → JSON
  config.js       environment → configuration, and the checks done at startup
  server.js       routing, CORS, cache headers, error handling
  preset.js       parsing of the _fit_w_h_q.ext segment
  signature.js    optional HMAC signing of URLs
  pipeline.js     sharp: decoding, rotation, resizing, encoding
  storage.js      sources, path safety, upstream download, disk cache
  converters.js   HEIC/HEVC and video poster frames, through external binaries
  semaphore.js    concurrency limiting
  logger.js       levelled logs

config.js is the only module that reads process.env: one place to look to know what is adjustable, and tests can build a configuration without touching the environment.

⁠Docker Hub image

https://hub.docker.com/r/smeagolworms4/image-resizer⁠

Published for linux/amd64, linux/arm64 and linux/arm/v7 from a single multi-arch manifest — the same tag works on a PC, a NAS and a Raspberry Pi.

TagBuilt on
latestevery push to main, and every git tag — the one to use
mainevery push to the main branch
<version> (e.g. 1.3.0)creation of a git tag of that name, to pin a version

→ How the image and the Lambda archives are built and published⁠ — the workflows, the GitHub secrets a fork needs, and how a version is cut.

⁠Troubleshooting

Configuration invalide : Aucune source configurée — the service refuses to start without a source. Set at least one SOURCE_<NAME>.

Every URL returns 400 Source '...' inconnue — the first URL segment is the source name, not a folder. SOURCE_PHOTOS=/data serves /photos/beach.jpg/_.webp.

404 on a file that does exist — check the mount inside the container (docker exec image-resizer ls /photos), and remember that a local source is rooted at its folder: /photos/2024/beach.jpg/_.webp reads <source>/2024/beach.jpg.

501 'heif-convert' is not installed — the HEIC converter is not on PATH; see HEIC.md⁠.

403 Signature manquante / Signature invalide — SIGNATURE_KEY is set, so every URL needs its signature. Check that the application signs <source>/<path>/<preset> decoded, with no leading slash and no BASE_PATH, and that both sides agree on SIGNATURE_LENGTH and SIGNATURE_ALGORITHM. The signing guide⁠ has reference values to compare against.

503 under load — that is the intended behaviour, not a bug. Raise MAX_CONCURRENCY if the machine can take it, and above all put a cache in front so those requests never reach it twice.

The cache volume is not writable — the container runs as UID 1000. Set PUID/PGID to match your own, or chown the cache folder.

⁠Security

Sources are named and fixed: no URL can make the service fetch an address you have not configured, which is what separates this from an open proxy. Paths are checked against the source root, so a ../ is rejected rather than resolved.

Output metadata is stripped by default (STRIP_METADATA=true), including GPS coordinates — worth keeping in mind before setting it to false on holiday photos.

SIGNATURE_KEY binds each URL to the variant it asks for: without the key, an address cannot be fabricated, and the service stops being a free image farm for anyone who can read your HTML. It is not access control — a signed URL stays valid for whoever holds it, which is what lets a cache keep it for months.

The service has no notion of authentication, and does not try to: it serves what its sources contain. Anything private belongs behind the cache or the reverse proxy that fronts it, where authentication is that layer's job.

Tag summary

Content type

Image

Digest

sha256:3e6092000…

Size

104.2 MB

Last updated

about 2 months ago

docker pull smeagolworms4/image-resizer