Build an Image Optimization API with Node.js and sharp
Build and deploy a production image optimization API with Node.js, Fastify, sharp, Redis caching and rate limiting on a small VPS.
Build a small image-optimization-as-a-service API that accepts an image URL or upload, resizes, reformats to WebP/AVIF and compresses on demand, with a Redis cache layer and per-key rate limiting. The stack is one Node.js application, sharp, Redis and Docker Compose. Verified 2026-09-08.
What we’re building
The finished MVP lets a client:
- Send an image URL or upload a multipart file.
- Request a target width, format (webp, avif, jpeg) and quality level.
- Receive the optimized buffer with the correct
Content-Typein a single round trip. - Get cache hits served directly from Redis on subsequent identical requests.
- Use an API key to enforce per-customer rate limits.
This is a focused transformation service, not a CDN. It does the CPU work once and caches the bytes; a real CDN in front of it still makes sense at scale. Move the cache to an external object store when the working set no longer fits in RAM.
flowchart LR
Client --> API[Fastify API]
API --> Redis[(Redis cache)]
API --> sharp[sharp]
sharp --> Redis
Fetch[fetch source] --> sharp
What you’ll learn
- How to stream an image through sharp without buffering the whole file.
- How to hash request parameters into a stable cache key.
- How to enforce per-key rate limits with
@fastify/rate-limitand a Redis store. - How to validate uploads and reject unsafe input before touching the codec.
- How to deploy a CPU-bound service behind Caddy with HTTPS on a small VPS.
Prerequisites
- Node.js 22.12 or newer.
- npm 10 or newer.
- Docker Engine and Compose v2.
- Redis 7, supplied by Compose locally.
- A VPS with at least 2 GB RAM, 20 GB disk and a DNS record.
Project overview
The API receives a request, computes a cache key from the source identifier plus the transformation options, and checks Redis. On a hit it returns the cached bytes. On a miss it fetches the source (or reads the upload), pipes it through sharp with the requested transforms, writes the output bytes to Redis with a TTL, and returns them. Per-key rate limiting is enforced before any work starts.
The source URL is fetched server-side. To avoid SSRF, the host is resolved and rejected if it points at a private or loopback address before the request leaves the process. Uploads bypass the fetch step but go through the same validation and transformation pipeline.
Project structure
image-optim-api/
├── src/
│ ├── app.js
│ ├── cache.js
│ ├── transform.js
│ ├── fetch.js
│ ├── keys.js
│ └── rate-limit.js
├── Dockerfile
├── docker-compose.yml
├── Caddyfile
├── package.json
└── .env.example
1. Create the project
mkdir image-optim-api && cd image-optim-api
npm init -y
npm pkg set type=module # ESM — lets us use top-level await in the cache warmup
npm i fastify @fastify/rate-limit @fastify/multipart @fastify/redis @redis/client sharp
@fastify/multipart handles uploads, @fastify/redis gives the rate limiter a shared store, and sharp is the codec. Pin sharp to the prebuilt binary path so the Docker build does not need libvips from source.
package.json (after install):
{
"name": "image-optim-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js"
}
}
2. Environment variables
.env (project root; not committed):
REDIS_HOST=localhost
REDIS_PORT=6379
CACHE_TTL_SECONDS=86400
MAX_UPLOAD_BYTES=10485760
RATE_LIMIT_PER_MINUTE=60
TRUSTED_PROXIES=1
.env.example (committed):
REDIS_HOST=localhost
REDIS_PORT=6379
CACHE_TTL_SECONDS=86400
MAX_UPLOAD_BYTES=10485760
RATE_LIMIT_PER_MINUTE=60
TRUSTED_PROXIES=1
3. API key store
API keys are stored as SHA-256 hashes. A key leak from this file does not immediately let an attacker make requests — they would still need to brute-force the preimage.
src/keys.js:
import { createHash } from 'node:crypto';
const KEY_STORE = new Map();
export function registerKey(rawKey) {
const id = createHash('sha256').update(rawKey).digest('hex');
KEY_STORE.set(id, { createdAt: new Date().toISOString() });
return id;
}
export function keyIdFromRaw(rawKey) {
const id = createHash('sha256').update(rawKey).digest('hex');
return KEY_STORE.has(id) ? id : null;
}
Keys are provisioned out of band (a CLI, a database, whatever fits your product). The guide keeps an in-memory map for clarity; replace it with a Postgres table before charging real money. A key is passed by the client as Authorization: Bearer <key>.
4. SSRF guard for URL sources
src/fetch.js:
import { request } from 'undici';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
const PRIVATE_RE = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc00:|fe80:)/i;
async function assertPublicHost(hostname) {
const addr = isIP(hostname) ? hostname : (await lookup(hostname)).address;
if (PRIVATE_RE.test(addr) || addr === '0.0.0.0') {
throw new Error(`Refusing to fetch private host: ${hostname} -> ${addr}`);
}
}
export async function fetchSourceBuffer(url, maxBytes) {
const target = new URL(url);
if (!['http:', 'https:'].includes(target.protocol)) {
throw new Error('Unsupported protocol');
}
await assertPublicHost(target.hostname);
const res = await request(target, { maxRedirections: 3 });
if (res.statusCode >= 400) {
throw new Error(`Source responded ${res.statusCode}`);
}
const len = Number(res.headers['content-length'] || 0);
if (len && len > maxBytes) {
throw new Error('Source too large');
}
return res.body;
}
The guard resolves the hostname and rejects any address in the loopback, private or link-local ranges before the HTTP request is issued. Keep the regex extended to any private range your VPS uses; IPv6 ULA and link-local are covered.
5. Cache layer
src/cache.js:
import { createClient } from '@redis/client';
const client = createClient({
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
});
await client.connect();
export async function getCached(key) {
const buf = await client.get(Buffer.from(key, 'utf8'));
return buf ? Buffer.from(buf, 'base64') : null;
}
export async function setCached(key, buffer, ttlSeconds) {
await client.set(key, buffer.toString('base64'), { EX: ttlSeconds });
}
export function cacheKeyFor(sourceId, options) {
const parts = [sourceId, options.width ?? 'auto', options.format, options.quality ?? '80'];
return createHashKey(parts.join('|'));
}
import { createHash } from 'node:crypto';
function createHashKey(str) {
return 'img:' + createHash('sha256').update(str).digest('hex');
}
The cache key is a SHA-256 of sourceId|width|format|quality, so identical transformations from different clients share bytes. The TTL defaults to 24h; raise it for static imagery, lower it for user-uploaded content that may change.
6. Transform pipeline
src/transform.js:
import sharp from 'sharp';
const ALLOWED_FORMATS = new Set(['webp', 'avif', 'jpeg', 'png']);
const MAX_WIDTH = 4000;
const MAX_QUALITY = 100;
export function normalizeOptions(raw) {
const format = String(raw.format || 'webp').toLowerCase();
if (!ALLOWED_FORMATS.has(format)) {
throw new Error(`Unsupported format: ${format}`);
}
const width = Number(raw.width || 0);
if (!Number.isInteger(width) || width < 0 || width > MAX_WIDTH) {
throw new Error('Invalid width');
}
const quality = Number(raw.quality || 80);
if (!Number.isInteger(quality) || quality < 1 || quality > MAX_QUALITY) {
throw new Error('Invalid quality');
}
return { format, width: width || undefined, quality };
}
export function transformStream(inputStream, options) {
let pipeline = sharp({ failOn: 'none' });
if (options.width) {
pipeline = pipeline.resize({ width: options.width, withoutEnlargement: true });
}
pipeline = pipeline.toFormat(options.format, { quality: options.quality });
return inputStream.pipe(pipeline);
}
export async function collectBuffer(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
withoutEnlargement: true means a 200px request on a 100px source returns the 100px original rather than a blurry upscale — the right default for a transformation API.
7. Rate limiting
src/rate-limit.js:
import rateLimit from '@fastify/rate-limit';
import redis from '@fastify/redis';
export async function installRateLimit(app) {
const perMinute = Number(process.env.RATE_LIMIT_PER_MINUTE || 60);
const trustedProxies = Number(process.env.TRUSTED_PROXIES || 1);
await app.register(redis, {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
closeClient: true,
});
await app.register(rateLimit, {
max: perMinute,
timeWindow: '1 minute',
redis: app.redis,
keyGenerator: (req) => req.keyId || req.ip,
trustProxy: trustedProxies,
errorResponseBuilder: () => ({ statusCode: 429, error: 'Too Many Requests', message: 'Rate limit exceeded' }),
});
}
The limiter uses the API key id when present and the client IP otherwise. trustProxy is set to the number of trusted hops in front of the API (Caddy is one) so X-Forwarded-For is honored correctly.
8. Application wiring
src/app.js:
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
import { installRateLimit } from './rate-limit.js';
import { fetchSourceBuffer } from './fetch.js';
import { transformStream, collectBuffer, normalizeOptions } from './transform.js';
import { getCached, setCached, cacheKeyFor } from './cache.js';
import { keyIdFromRaw } from './keys.js';
const app = Fastify({ logger: true, bodyLimit: Number(process.env.MAX_UPLOAD_BYTES || 10485760) });
await app.register(multipart, { limits: { fileSize: Number(process.env.MAX_UPLOAD_BYTES || 10485760) } });
await installRateLimit(app);
function authenticate(req) {
const header = req.headers.authorization || '';
const match = /^Bearer\s+(.+)$/.exec(header);
if (!match) return null;
return keyIdFromRaw(match[1]);
}
const CONTENT_TYPES = { webp: 'image/webp', avif: 'image/avif', jpeg: 'image/jpeg', png: 'image/png' };
app.get('/health', async () => ({ status: 'ok' }));
app.post('/optimize', async (req, reply) => {
const keyId = authenticate(req);
if (!keyId) return reply.code(401).send({ error: 'Missing or invalid API key' });
req.keyId = keyId;
try {
const options = normalizeOptions(req.query);
const sourceUrl = req.query.url;
let sourceId;
let inputStream;
if (sourceUrl) {
sourceId = 'url:' + sourceUrl;
inputStream = await fetchSourceBuffer(sourceUrl, app.bodyLimit);
} else {
const file = await req.file();
if (!file) return reply.code(400).send({ error: 'No image provided' });
sourceId = 'upload:' + file.fieldname + ':' + file.filename;
inputStream = file.file;
}
const cacheKey = cacheKeyFor(sourceId, options);
const hit = await getCached(cacheKey);
if (hit) {
reply.header('X-Cache', 'HIT');
reply.header('Content-Type', CONTENT_TYPES[options.format]);
return reply.send(hit);
}
const outStream = transformStream(inputStream, options);
const outBuffer = await collectBuffer(outStream);
await setCached(cacheKey, outBuffer, Number(process.env.CACHE_TTL_SECONDS || 86400));
reply.header('X-Cache', 'MISS');
reply.header('Content-Type', CONTENT_TYPES[options.format]);
return reply.send(outBuffer);
} catch (err) {
req.log.error({ err: err.message }, 'optimize failed');
return reply.code(400).send({ error: err.message });
}
});
const port = Number(process.env.PORT || 3000);
app.listen({ port, host: '0.0.0.0' }).then((addr) => app.log.info(`listening on ${addr}`));
The handler authenticates first (so anonymous traffic never reaches the codec), validates options, computes the cache key, short-circuits on a hit, and only then runs sharp. Every error path returns a JSON body with a status code; a request with no key, no image, an oversized upload or a private host all fail with a clear message.
9. Running locally
docker compose up -d redis
npm run dev
# in another terminal
curl -s -H "Authorization: Bearer test-key" \
"http://localhost:3000/optimize?url=https://placecats.com/neo/640/480&width=320&format=webp&quality=70" \
-o out.webp
Register the test key once in a REPL:
import { registerKey } from './src/keys.js';
registerKey('test-key');
The second identical request should come back with X-Cache: HIT and the same byte length. A request with url=http://127.0.0.1 is rejected with Refusing to fetch private host.
10. Docker setup
Dockerfile:
FROM node:22-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libvips-dev && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "src/app.js"]
docker-compose.yml:
services:
redis:
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
volumes:
- redis-data:/data
api:
build: .
environment:
REDIS_HOST: redis
REDIS_PORT: "6379"
CACHE_TTL_SECONDS: "86400"
MAX_UPLOAD_BYTES: "10485760"
RATE_LIMIT_PER_MINUTE: "60"
TRUSTED_PROXIES: "1"
ports:
- "3000:3000"
depends_on:
redis: { condition: service_healthy }
restart: unless-stopped
volumes:
redis-data:
Redis persistence is on (/data volume) so a container restart does not evict the whole cache. sharp is CPU-bound; add more api replicas behind Caddy when the single container saturates a core.
11. Production deployment
On the VPS:
# 1. SSH in, clone the repo
git clone https://github.com/yourname/image-optim-api.git
cd image-optim-api
# 2. Build and start
docker compose up -d --build
# 3. Caddy for HTTPS
sudo apt install caddy
Caddyfile (project root):
optim.your-domain.com {
reverse_proxy api:3000
}
Move it to /etc/caddy/Caddyfile and sudo systemctl reload caddy. Caddy fetches and renews the Let’s Encrypt certificate automatically; no cron, no certbot.
12. Recommended infrastructure
A 2 GB VPS is enough for a single worker plus Redis with a working set up to a few hundred MB. For higher throughput or larger caches:
| Workload | VPS | RAM for Redis | Notes |
|---|---|---|---|
| <5 req/s, small cache | 2 GB | 512 MB | Hetzner CX22, ~$4.5/mo |
| 5–20 req/s | 4 GB | 1 GB | Hetzner CX32, ~$8.5/mo |
| 20+ req/s | 8 GB / 2 vCPU | 2 GB | Add a second api replica |
sharp is CPU-bound, so more vCPU matters more than RAM once the cache is warm.
Cost
Assumptions: 100k transformations/month, 80% cache hit rate, 150 KB average output, 3.2 GB cache working set.
| Service | Monthly cost |
|---|---|
| VPS 4 GB (Hetzner CX32) | $8.49 |
| Bandwidth (100 GB included) | $0.00 |
| Domain | $1.00 |
| Total | ≈ $9.49/month |
Cache misses cost CPU; cache hits cost almost nothing. The 80% hit rate assumption is the lever — if your traffic is long-tail (every image unique), CPU dominates and you scale vCPU instead of cache.
Production checklist
- HTTPS via Caddy (automatic Let’s Encrypt)
- Redis persistence enabled (
/datavolume) - API keys issued per customer, stored hashed
- Rate limit per key, not per IP
- SSRF guard covers loopback, private, link-local and IPv6 ULA
- Upload size limit enforced at Fastify and multipart layers
- Health endpoint monitored (
/health) - Backups of the Redis volume if the cache is expensive to rebuild
-
restart: unless-stoppedon both services
Environment variables
| Variable | Required | Description |
|---|---|---|
REDIS_HOST |
yes | Redis hostname (redis in Compose) |
REDIS_PORT |
yes | Redis port (6379) |
CACHE_TTL_SECONDS |
no | Cache entry TTL, default 86400 (24h) |
MAX_UPLOAD_BYTES |
no | Upload size cap, default 10 MB |
RATE_LIMIT_PER_MINUTE |
no | Requests per key per minute, default 60 |
TRUSTED_PROXIES |
no | Hops to trust for X-Forwarded-For, default 1 |
PORT |
no | Listen port, default 3000 |
Common problems
- sharp install fails in Docker: the
node:22-slimimage lackslibvips. The Dockerfile above installslibvips-dev; if you switch base images, rerunnpm rebuild sharp. - Cache memory grows without bound: Redis is in RAM. Set
maxmemoryand aallkeys-lrupolicy inredis.conf(orredis-server --maxmemory 512mb --maxmemory-policy allkeys-lru) so the cache self-trims. - SSRF via DNS rebinding: the guard resolves once and checks the address, but a malicious server can return a different IP on the actual fetch. For a hard target, pin the resolved IP with a custom
lookuphook inundiciso the fetch uses the same address you checked. X-Cache: MISSon every request: the cache key includesquality, so a client sendingquality=81instead of80misses. Document the defaults and reject fractional qualities (the validator already does).- Uploads of zero bytes:
@fastify/multipartyields an empty stream if the field is malformed. Checkfile.file.readableLengthor attempt a read before handing to sharp.
Improvements
- A
GET /optimize?url=...variant that streams the response straight to the client instead of buffering — useful for large outputs once you trust the cache layer. - Webhook or signed-URL output to object storage (R2, S3) so the API stays stateless and the cache lives on disk, not RAM.
- Per-key billing: an hourly job that reads the rate-limit counters from Redis and writes a usage row per key.
- AVIF is slow on large sources; add a
maxPixelsguard that down-scales before the AVIF encode. - A
/metadataendpoint returning width, height, format and dominant color without re-encoding.
Conclusion
An image optimization API is a thin layer over sharp plus a cache. With Fastify, a Redis cache keyed on the transformation parameters and a per-key rate limit, you get a billable, stateless service that scales by adding replicas — all of it reproducible from this guide for under $10/month.