Stop thinking about image optimization.
Rendorix is a developer-first image CDN: upload originals to S3, request transforms through the URL, cache at the edge. The long-term direction is presets over parameters — define roles like hero, card, and avatar once, then call /photo.jpg?p=hero instead of juggling w, q, and f on every tag.
For the full product story (problem, philosophy, planned DX), see Marketing.md.
- AWS stack: CloudFront → CloudFront Function (signed URL validation) → Lambda (Sharp) → private S3.
- Transforms: query parameters
w,h,f,qwith HMAC-signed URLs (exp+s). - CLI:
scripts/sign-url.jsfor generating signed paths.
- Semantic presets (
p=hero) mapped to shared transform definitions. - A small JavaScript helper that builds signed URLs (and eventually preset-based URLs) so apps don’t reimplement signing.
- Optional composable presets and framework-focused guides.
Image tuning shouldn’t be a micro-decision on every <img>. The goal is constraints over chaos — a small set of named recipes your whole team shares, plus infrastructure that stays minimal: no heavy dashboard, no SDK required for the core HTTP API.
Client Request
│
▼
┌──────────────┐ cache hit
│ CloudFront │ ────────────► Response (fast)
│ (CDN) │
└──────┬───────┘
│ cache miss
▼
┌──────────────┐
│ Lambda │ ◄── Node.js + Sharp
│ (process) │
└──────┬───────┘
│ fetch original
▼
┌──────────────┐
│ S3 │ ◄── Private bucket, originals only
│ (storage) │
└───────────--─┘
- A request hits CloudFront, which runs a CloudFront Function on every viewer request.
- The CloudFront Function validates the HMAC-SHA256 signature and expiration, then strips
sandexpfrom the query string before forwarding. - On a cache miss, CloudFront forwards the clean request (transform params only — today
w/h/f/q) to a Lambda function. - Lambda fetches the original image from S3, applies the requested transformations using Sharp, and returns the result.
- CloudFront caches the transformed image. Subsequent identical requests are served from the edge.
The image path maps directly to the S3 object key. Transformations use query parameters:
| Parameter | Description | Example | Default |
|---|---|---|---|
w |
Width in pixels | w=800 |
Original width |
h |
Height in pixels | h=600 |
Original height |
f |
Output format | f=webp |
jpeg |
q |
Quality (1–100) | q=80 |
80 |
Supported formats: jpeg, webp, png, avif
Roadmap: preset-based URLs such as ?p=hero that resolve to curated width, quality, and format — see Marketing.md.
# Resize to 400px wide, keep aspect ratio
/photos/hero.jpg?w=400
# Convert to WebP at 75% quality
/photos/hero.jpg?f=webp&q=75
# Resize and convert in one request
/photos/hero.jpg?w=1200&h=630&f=webp&q=85
# Serve the original (no transforms)
/photos/hero.jpg
Images are resized to fit within the given dimensions without upscaling or distorting aspect ratio.
| Layer | Technology | Purpose |
|---|---|---|
| CDN | CloudFront | Edge caching, HTTPS termination |
| Auth | CloudFront Function (cloudfront-js-2.0) |
HMAC-SHA256 signed URL validation at the edge |
| Compute | Lambda (Node.js 20.x) | Image processing on demand |
| Processing | Sharp | Resize, format conversion, quality control |
| Storage | S3 | Private bucket for original images |
| Infrastructure | Terraform | Provision and manage all AWS resources |
| IAM | Least-privilege roles | Lambda can only read from S3 and write logs |
rendorix/
├── cloudfront-function/
│ └── signer.js.tpl # CloudFront Function source (secrets injected by Terraform)
├── lambda/
│ ├── index.js # Lambda handler (image processing)
│ ├── package.json
│ └── function.zip # Deployment artifact (built locally)
├── scripts/
│ └── sign-url.js # CLI helper to generate signed URLs
├── terraform/
│ ├── main.tf # S3, Lambda, CloudFront Function, CloudFront, IAM
│ ├── providers.tf # AWS provider + version constraints
│ ├── variables.tf # Configurable inputs
│ └── outputs.tf # CloudFront URL, Lambda URL, bucket name
├── Marketing.md # Positioning, vision, planned DX
├── .gitignore
└── README.md
Sharp requires platform-specific native binaries. Since Lambda runs on Amazon Linux, you need to install the Linux binaries regardless of your local OS:
cd lambda
# Install dependencies
npm install
# Add Linux-specific Sharp binaries (required if building on macOS/Windows)
npm pack @img/sharp-linux-x64@0.34.5 @img/sharp-libvips-linux-x64@1.2.4
mkdir -p node_modules/@img/sharp-linux-x64 node_modules/@img/sharp-libvips-linux-x64
tar xzf img-sharp-linux-x64-0.34.5.tgz -C node_modules/@img/sharp-linux-x64 --strip-components=1
tar xzf img-sharp-libvips-linux-x64-1.2.4.tgz -C node_modules/@img/sharp-libvips-linux-x64 --strip-components=1
rm -f *.tgz
# Create the deployment zip
zip -r function.zip index.js node_modules/cd terraform
terraform init
terraform plan
terraform applyTerraform will output:
cloudfront_url— your CDN endpointlambda_function_url— direct Lambda URL (useful for debugging)s3_bucket_name— the bucket to upload images to
aws s3 cp test.jpg s3://$(terraform -chdir=terraform output -raw s3_bucket_name)/test.jpgcurl "https://$(terraform -chdir=terraform output -raw cloudfront_url)/test.jpg?w=400&f=webp&q=80" --output test-400.webp(Production use requires signing the URL first — see Signed URLs below.)
You can override defaults by passing variables to Terraform:
terraform apply \
-var="bucket_name=my-custom-bucket" \
-var="aws_region=eu-west-1" \
-var="signing_secret=your-secret-here"| Variable | Description | Default |
|---|---|---|
aws_region |
AWS region to deploy into | us-east-1 |
bucket_name |
S3 bucket name for original images | rendorix-cdn-images |
signing_secret |
HMAC secret for signing image URLs (required) | — |
signing_secret_previous |
Previous secret, set during key rotation (see below). Optional. | "" |
All requests to the CDN must be signed. Unsigned requests are rejected at the CloudFront edge by a CloudFront Function before they ever reach the origin.
- Construct the image URL with your transform parameters.
- Add an expiration timestamp (
exp) as a Unix timestamp in seconds. - Canonicalize the parameters: lowercase all keys, sort alphabetically, exclude
s. - Compute
HMAC-SHA256(secret, "/path?canonicalized_params")and hex-encode it. - Append
&s=<signature>to the URL.
The signature and expiration are stripped by the CloudFront Function before forwarding to the cache, so URLs with different expiration times share the same cache entry for the same image and transforms.
Use the included helper script. It reads the secret from RENDORIX_SECRET and accepts an optional --ttl flag (default: 1 hour).
# Sign a URL, expires in 1 hour (default)
RENDORIX_SECRET=your-secret node scripts/sign-url.js "/photo.jpg?w=800&f=webp"
# => /photo.jpg?exp=1714003600&f=webp&w=800&s=a3f1b2c4...
# Sign a URL with a 24-hour TTL
RENDORIX_SECRET=your-secret node scripts/sign-url.js "/photo.jpg?w=800&f=webp" --ttl 86400Use your CloudFront domain to build the full URL:
CDN="https://$(terraform -chdir=terraform output -raw cloudfront_url)"
SIGNED=$(RENDORIX_SECRET=your-secret node scripts/sign-url.js "/photo.jpg?w=800&f=webp")
echo "$CDN$SIGNED"Sign URLs on your server — never expose the secret to the client. Example in Node.js:
const crypto = require("crypto");
function signUrl(path, params, secret, ttlSeconds = 3600) {
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
const allParams = { ...params, exp: String(exp) };
const canonical = Object.entries(allParams)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join("&");
const sig = crypto
.createHmac("sha256", secret)
.update(`${path}?${canonical}`)
.digest("hex");
return `${path}?${canonical}&s=${sig}`;
}
// Usage
const url = signUrl("/photo.jpg", { w: "800", f: "webp" }, process.env.RENDORIX_SECRET);
// => /photo.jpg?exp=1714003600&f=webp&w=800&s=a3f1b2c4...Rotating the secret without downtime requires two deploys:
Step 1 — add the old secret as the fallback:
terraform apply \
-var="signing_secret=new-secret" \
-var="signing_secret_previous=old-secret"The CloudFront Function will now accept URLs signed by either secret.
Step 2 — update your application to sign new URLs with new-secret. Wait for all previously issued URLs to expire.
Step 3 — remove the fallback:
terraform apply \
-var="signing_secret=new-secret" \
-var="signing_secret_previous="Ideas and direction — not a committed schedule. Infrastructure and product tracks:
Product / DX
- Signed URLs with HMAC-SHA256 and expiration
- Presets (
p=hero) backed by shared transform definitions - JavaScript helper for URL building and signing (aligns with
Marketing.md) - Composable or chained presets
- Framework notes (Astro, Next.js, etc.)
Infrastructure
- Custom domain with ACM certificate
- Processed image caching in a separate S3 bucket (optional)
- Automated Lambda packaging (CI/CD)
- Additional transform modes (e.g. crop cover / contain / fill) if needed beyond presets
- CloudFront cache invalidation workflow
- Rate limiting via AWS WAF
- Secrets Manager (or similar) for signing secrets
- Monitoring and alerting (CloudWatch)
MIT