A generic, self-hosted on-the-fly image resizing and format-conversion service for any S3 + CloudFront setup, running as a Lambda@Edge origin-request function.
https://your-cdn.example.com/uploads/products/photo.jpg?w=500&h=333&f=webp
fits the image inside a 500×333 box (preserving aspect ratio — by default it never crops, distorts or upscales past the source resolution), converts it to WebP, and caches the result at the CloudFront edge — indefinitely, since a given (key, w, h, f, crop) combination always produces the same output.
Most "serverless image resize" tutorials have the Lambda return the resized image bytes directly as its HTTP response. That works until an image is bigger than 1 MB, at which point it breaks — Lambda@Edge caps generated responses at 1 MB, and real photos routinely exceed that.
This project takes a different approach: on origin-request, the Lambda checks whether a resized derivative already exists in S3. If not, it computes one, uploads it to S3, and rewrites the CloudFront request's URI to point at that derivative key instead — it never returns image bytes itself. CloudFront then fetches the derivative from S3 exactly like any other object, through your existing origin. No response-size limit, no new CloudFront origin, no public-facing Lambda URL to secure.
This is how you can deploy to your own AWS account.
-
Needs: an existing S3+CloudFront setup and AWS CLI access. Python 3.13 + pip only if you build it yourself.
-
Get a zip — there are two variants, and nothing in either is specific to your account, since all configuration comes from CloudFront headers (see How it's configured):
function.zip— JPEG, PNG, GIF, WebP, AVIF, TIFF and BMP. Pillow and its bundled libraries only.function-full.zip— everything above plus JPEG XL and HEIC (iPhone photos). It bundles GPL-licensed components, so read Licensing first.
Get either one as a release download or by building it yourself:
- Download a release (no build needed): grab the zip you want and its
.sha256file from the latest release, and verify it (shown forfunction.zip; usefunction-full.zipfor the full variant):To pin a version, replacecurl -LO https://github.com/kalimulhaq/lambda-image-resizer/releases/latest/download/function.zip curl -LO https://github.com/kalimulhaq/lambda-image-resizer/releases/latest/download/function.zip.sha256 sha256sum -c function.zip.sha256
latest/downloadwithdownload/v0.2.0(or any other tag). - Or build it yourself: clone the repo and run
./scripts/build.shforfunction.zip, or./scripts/build.sh --fullforfunction-full.zip. It packages Pillow (and, for--full, the JPEG XL and HEIC plugins), fetched as Lambda-compatible prebuilt wheels with no Docker needed, plus the handler.
-
IAM: create an execution role using
infra/trust-policy.example.json(note it must trust bothlambda.amazonaws.comandedgelambda.amazonaws.com— a Lambda@Edge-specific requirement) andinfra/s3-policy.example.json(fill in your bucket name). The S3 policy only lets the function write underresized/*— if you changeX-Img-Resized-Prefix, change that resource too. -
Deploy (using
function-full.zipinstead offunction.zipif you chose the full variant —ZIP_FILE=function-full.zipfordeploy.sh) the function inus-east-1(required for Lambda@Edge regardless of your bucket's region) and publish a version. It runs onpython3.13(x86_64 — Lambda@Edge has no arm64), with a 30s timeout and 1024MB memory. Lambda CPU scales with memory, so 1024MB resizes roughly twice as fast as 512MB for about the same cost.- With a clone of the repo:
FUNCTION_NAME=lambda-image-resizer ROLE_ARN=<your-role-arn> ./scripts/deploy.sh— creates or updates the function and publishes a version (override withTIMEOUT=/MEMORY_SIZE=). - With only the downloaded zip, first deploy:
To upgrade later, download the new release and run:
aws lambda create-function --region us-east-1 \ --function-name lambda-image-resizer --runtime python3.13 --architectures x86_64 \ --handler lambda_image_resizer.handler.handler --role <your-role-arn> \ --zip-file fileb://function.zip --timeout 30 --memory-size 1024 aws lambda wait function-active-v2 --region us-east-1 --function-name lambda-image-resizer aws lambda publish-version --region us-east-1 --function-name lambda-image-resizer --query FunctionArn --output text
then point your CloudFront association at the new version ARN (step 5), and read that release's notes for any other upgrade steps.aws lambda update-function-code --region us-east-1 --function-name lambda-image-resizer --zip-file fileb://function.zip aws lambda wait function-updated-v2 --region us-east-1 --function-name lambda-image-resizer aws lambda publish-version --region us-east-1 --function-name lambda-image-resizer --query FunctionArn --output text
- With a clone of the repo:
-
Wire into CloudFront:
- Add the
X-Img-*custom headers to your existing S3 origin (infra/origin-custom-headers.example.json) — onlyX-Img-Bucketis required. - Add the published version's ARN as an
origin-requestLambda association on your cache behavior (infra/lambda-function-association.example.json). - Create a cache policy that forwards
w/h/d/crop/fas part of the cache key (infra/cache-policy.example.json) and assign it to that behavior — the defaultCachingOptimizedmanaged policy forwards zero query strings, which would make every combination collapse onto one cache entry. Leaving outdorcrophas the same effect for those parameters: a cropped and an uncropped request would be served from one cache entry. - Create a response headers policy (
infra/response-headers-policy.example.json) and assign it to that behavior — see Response headers. - Recommended: enable Origin Shield on the S3 origin, in the region closest to your bucket. The origin-request Lambda runs at the regional edge caches, so Origin Shield collapses requests from all of them into one — fewer Lambda invocations and more cache hits.
- Add the
-
Wait: Lambda@Edge association changes propagate to edge locations over several minutes (often 15–30+) — longer than a typical CloudFront config change.
Lambda@Edge doesn't support environment variables, so per-distribution configuration is passed via CloudFront origin custom headers instead — this is what makes one deployed function reusable across multiple, unrelated projects/buckets with zero code changes:
| Header | Meaning | Required |
|---|---|---|
X-Img-Bucket |
S3 bucket holding originals (and where derivatives get written) | Yes |
X-Img-Resized-Prefix |
Key prefix for derivatives | No — defaults to resized |
X-Img-Quality |
JPEG/WebP/AVIF/JPEG XL save quality (1–100; 100 is lossless for JPEG XL, full build only) |
No — defaults to 82 |
X-Img-Max-Dimension |
Upper clamp for w/h (16–8192) |
No — defaults to 4096 |
X-Img-Allowed-Sizes |
Comma-separated sizes, e.g. 320,640,960,1280,1920,2560. Requested w/h are rounded up to the nearest one (or down to the largest) |
No — off by default, recommended in production |
X-Img-Cache-Control |
Cache-Control stored on each derivative |
No — defaults to public, max-age=31536000, immutable |
See infra/origin-custom-headers.example.json.
CloudFront runs an origin-request function once per origin it contacts, so with an origin group the function can run twice for one viewer request: once for the primary origin and again for the secondary if the primary returns a failover status. Each run reads the X-Img-* headers of the origin it is running for, so:
- Every origin in the group needs its own
X-Img-*headers, withX-Img-Bucketset to that origin's bucket. Without it the function passes the request through for that origin and the original is served unresized. - The execution role needs access to every bucket: read the originals and write under the resized prefix in each one (
infra/s3-policy.example.jsonlists one bucket; add a statement per bucket). Each bucket gets its own derivatives. - Test the failover path on a staging distribution before relying on it.
viewer request
│
▼
CloudFront (existing cache behavior, existing S3 origin — unchanged)
│
├─ origin-request Lambda@Edge trigger (this project)
│ │
│ ├─ no w/h/d/f, or ineligible source type → pass through unchanged
│ ├─ derivative already in S3 → rewrite request URI, done
│ └─ derivative missing → fetch original, resize/convert with
│ Pillow, upload derivative to S3, rewrite request URI
▼
S3 (existing origin, existing bucket policy — unchanged) serves whichever
key the request now points at, exactly as it always has
No new CloudFront origin. No new cache behavior/path pattern required (though you can scope it to one if you prefer). No bucket policy change. No public-facing endpoint.
| Param | Meaning | Notes |
|---|---|---|
w |
Target width in px | Optional. May be given alone. |
h |
Target height in px | Optional. May be given alone. |
d |
Width as WxH |
Shorthand for w: d=500x333 scales to 500px wide and the height follows (the 333 is ignored). d=400x is the same, d=x250 scales to 250px high. Ignored if w or h is given. |
crop |
Exact-size crop | crop=true with both dimensions (w and h, or d=WxH) returns exactly that size. Optional. |
f |
Output format | One of webp, avif, jpeg, jpg, png, or jxl (full build only). Optional — omit to keep the source format. |
wandhare independent: give one, both, or neither.- Neither given → no resize (still useful for format-only conversion, or as a pure pass-through if
fis also omitted). - Both given → the image is scaled to fit inside that box (Pillow's "contain" semantics) — aspect ratio is always preserved, the image is never cropped, and it is never enlarged past its native resolution.
d=WxHis a width-only shorthand (see the table), so it does not fit the image inside a box: a portrait photo requested withd=500x333comes out 500px wide and taller than 333px. Usewandhfor a box.crop=trueis the one case that returns an exact size. The image is scaled to cover the box and cropped from the centre, and it is enlarged if the source is smaller than the box. It needs both dimensions — with only one,cropis ignored. Exact sizes are not snapped toX-Img-Allowed-Sizes(snapping each side would change the box's shape), but they are still clamped toX-Img-Max-Dimension, so a crop is not covered by the cap thatX-Img-Allowed-Sizesputs on the number of derivatives.- Every invalid or out-of-range input results in the original file being served, never an error. Bad
w/hvalues are clamped into a sane range (16–4096px by default, seeX-Img-Max-Dimension) rather than rejected; an unsupportedfis ignored; a request against a non-image file type is passed straight through without even being looked at. - Photos are rotated upright according to their EXIF orientation. EXIF metadata (camera, GPS location) is stripped from derivatives; the embedded colour profile is kept.
- Originals over 25 MB or 50 megapixels are served as-is rather than processed.
bmpis readable as a source but not requestable viaf=—?f=bmpis ignored like any unsupported format.- JPEG XL and HEIC need the full build. With
function.zip,f=jxlis ignored and.jxl,.heicand.heiffiles are passed through untouched. - Sources that browsers can't display — HEIC/HEIF (iPhone photos) and TIFF — are converted to JPEG when no
f=is given. Ask for another format withf=, for examplephoto.heic?w=800&f=webp. - JPEG XL output is always written as sRGB: unlike the other formats, an embedded colour profile on the source (for example Display P3) is not carried over. Browser support for JPEG XL is still uneven, so only request it where you know the viewer can decode it.
Only certain source file types are eligible for resizing at all — jpg, jpeg, jfif, png, gif, webp, avif, tif, tiff and bmp, plus jxl, heic and heif in the full build. Anything else (including .svg, which is vector and shouldn't be raster-resized) is passed straight through, regardless of query params.
| Request | Result |
|---|---|
photo.jpg |
untouched |
photo.jpg?w=500 |
resized to 500px wide, aspect preserved |
photo.jpg?w=500&h=333 |
fit inside 500×333 box |
photo.jpg?d=500x333 |
resized to 500px wide, aspect preserved (height follows; same derivative as w=500) |
photo.jpg?d=520x400&crop=true |
exactly 520×400, cropped from the centre |
photo.jpg?f=webp |
format converted, no resize |
photo.png?f=jxl |
converted to JPEG XL, no resize (full build) |
photo.heic?w=800 |
iPhone photo resized to 800px wide and served as a JPEG (full build) |
photo.jpg?w=99999 |
clamped to max (4096px by default) |
logo.svg?w=500 |
untouched — SVG never resized |
Each derivative is written to S3 with Content-Type and Cache-Control: public, max-age=31536000, immutable (configurable via X-Img-Cache-Control). Since a derivative's key never changes content, browsers and CloudFront can keep it for a year without revalidating.
S3 adds no security headers and leaks a few origin details, so attach infra/response-headers-policy.example.json to the cache behavior as a CloudFront response headers policy. It:
- adds
X-Content-Type-Options: nosniff,Strict-Transport-Security,Referrer-Policy,Cross-Origin-Resource-Policy: cross-origin(so your site can embed images from the CDN hostname) andTiming-Allow-Origin(so real-user monitoring can see image load timings); - adds
Cache-Control: public, max-age=86400only when the object has none — this covers originals and derivatives created before v0.2.0; - removes
Server,x-amz-server-side-encryptionandx-amz-version-id.
If you draw these images onto a <canvas> or fetch them from JavaScript, also add a CorsConfig to that policy.
Every distinct w/h/f/crop combination is a separate derivative: it costs one resize and is stored in S3 forever. With only clamping, anyone can request w=100, w=101, … and create thousands of derivatives per image. Setting X-Img-Allowed-Sizes caps that at a handful per image — pick the sizes your srcsets actually use.
Derivatives are keyed by (key, w, h, f, crop) only — not content. Overwriting an original at the same S3 key does not invalidate its derivatives; they'll keep being served, forever — and since they're sent with immutable, browsers won't re-check them either. If you replace images, use a new key/path rather than overwriting in place.
To regenerate every derivative (e.g. after upgrading, to pick up the new Cache-Control and encoder settings), delete the resized/ prefix and invalidate /* in CloudFront.
Tests mock S3 via moto — no AWS credentials needed.
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
ruff check .
ruff format --check .
mypy src/
pytest --cov=srcThe dev extra installs the optional JPEG XL and HEIC plugins too, so the whole suite runs. tests/test_codecs.py covers the case where they are missing.
This project is MIT-licensed. The default function.zip contains Pillow (HPND licence) and the libraries bundled in its wheels, which use permissive licences such as BSD, MIT, zlib and FreeType's. Their licence texts are in THIRD_PARTY_LICENSES/ inside each zip.
JPEG XL and HEIC come from separate packages with stricter licences, which is why they are optional:
| Format | Package | Licence |
|---|---|---|
| JPEG XL | pillow-jxl-plugin |
GPL-3.0-or-later |
| HEIC / HEIF | pillow-heif binary wheels |
Labelled GPLv2: they bundle libheif and libde265 (LGPLv3) and the x265 encoder (GPLv2) |
function-full.zip bundles both, so treat it as GPL-licensed if you copy, modify or redistribute it. HEIC is also based on the HEVC video codec, which can need patent licences in some jurisdictions. This is a summary, not legal advice. If that matters for your use, deploy function.zip or check with your own counsel.
Please see CONTRIBUTING for details.
If you discover a security issue please email kalim.dir@gmail.com rather than using the public issue tracker.
MIT — see LICENSE.
If you find this project useful, consider supporting me on Ko-fi!