Shutter is an image optimization and preview service for applications that keep their own uploads, storage, media records, and end-user authorization. It turns images, videos, and PDFs into optimized WebP images and durable thumbnails without taking custody of any of it.
Most image CDNs become the system of record. You upload to them, they own the bytes, and private media either leaks into a public URL space or forces you to proxy every request through your own app. Shutter takes the opposite position. Applications issue short-lived, encrypted capability tokens for the sources they are willing to expose, and Shutter renders only what a token authorizes. The application stays the authority, and Shutter never learns its domain model.
Architecture · Decision records · v1 contracts · Development · Runbooks
Resize to a requested width and WebP-encode at a requested quality, preserving composition. Widths and qualities come from Space policy, not from the caller. Non-canonical public values redirect once to their canonical form and cache there.
GET /v1/public/{space}/resolver/{resolver}/{ref}?w=1200&q=75
GET /v1/public/{space}/located/{sourceId}/{cap}?w=1200&q=75
One durable Master Preview per source: a one-second video frame with a
first-decodable-frame fallback, or the first PDF page, encoded as quality-90
WebP with its longest edge at most 1920px. Every responsive thumbnail is an
Image Optimization of that master. Jobs are keyed by (space, source, kind),
so there is no client-side idempotency key and no duplicate work.
PUT /v1/spaces/{space}/sources/{sourceId}/previews/video
Authorization: Bearer <space-api-token>
{ "sourceCapability": "v1.<kid>.<iv>.<ciphertext-and-tag>" }{
"status": "ready",
"master": {
"sourceId": "application-issued-source-id",
"kind": "video", "width": 1920, "height": 1080,
"format": "webp"
}
}A Cloudflare Worker decrypts and validates the capability before any cache
lookup, so an authorization failure can never be served from cache. Browser
responses are private, no-store. The internal cache key excludes the expiring
capability, so hits survive token rotation.
GET /v1/private/{space}/source/{cap}?w=1200&q=75
GET /v1/private/{space}/master/{cap}?w=1200&q=75
Full wire behavior: delivery URLs · job API · source capability · source purge
Your application mints capabilities and builds URLs with @shutter/protocol,
or uses @shutter/client, which wraps every consumer
endpoint behind one configured object. Nothing about the source leaves your
side except what a single capability authorizes.
import { issueSourceCapability, buildPrivateSourceUrl } from "@shutter/protocol";
const now = Math.floor(Date.now() / 1000);
// after your own authorization check for this user and this media record
const capability = await issueSourceCapability(
{
space_id: "demo-private",
source_id: "media_01H8...", // immutable; changed bytes are a new source
purpose: "image_source",
locator: presignedGetUrl, // replaceable; identity does not depend on it
iat: now,
exp: now + 300,
},
{ kid: "key-2026-07", key: spaceKey },
);
const src = buildPrivateSourceUrl("demo-private", capability, {
width: 1200,
quality: 75,
});Three purposes, each bound to exactly one job: image_source optimizes a
private original, master_preview reads a stored Master Preview, preview_job runs
one bounded Preview Job. A capability carries a Source Locator only when its
purpose has to fetch your bytes.
import { buildPreviewJobUrl } from "@shutter/protocol";
await fetch(controlBaseUrl + buildPreviewJobUrl(space, sourceId, "video"), {
method: "PUT",
headers: {
authorization: `Bearer ${spaceApiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ sourceCapability }),
});202 while pending or processing (with Location and Retry-After), 200
once the job reaches ready or a persisted failed. Poll the same canonical
GET; the JSON status is authoritative. Repeat PUTs are safe. A fresh
capability reactivates a source_expired or attempts_exhausted job on the
same record.
POST /v1/spaces/{space}/sources/{sourceId}/purge
After you have made the original unavailable. It removes every cached optimized image, stored Master Preview, and Preview Job for that immutable identity. It does not revoke an otherwise-valid capability. Short lifetimes do that.
flowchart LR
app["Consuming application"] --> control["Shutter Control"]
app --> source["Application-owned S3 storage"]
app -->|"Preview Job"| control
control --> jobs[("Preview Jobs")]
jobs --> video["Video Executor"]
jobs --> pdf["PDF Executor"]
video --> derived["Media Store on R2"]
pdf --> derived
browser["Browser"] --> worker["Cloudflare Worker"]
worker --> privateCache["Private edge cache"]
browser --> publicCache["Cloudflare public CDN"]
privateCache --> origin["Shutter origin on Railway"]
publicCache --> origin
origin --> image["imgproxy"] --> source
worker --> derived
origin --> derived
app -->|"Source Capability after app authorization"| browser
Browser requests a private delivery URL
-> Worker decrypts the capability and checks purpose, space, source, expiry
-> Reject: 401/403, nothing is cached, no cache is consulted
-> Normalize w and q, derive the internal cache key (capability excluded)
-> Private edge cache hit: serve, still validated on every lookup
-> Miss: origin on Railway -> imgproxy -> application storage via locator
-> Respond `private, no-store`; store a clone at the internal key for 24h
Application PUTs a preview_job capability
-> Control creates or returns the job at (space, source, kind)
-> Executor claims one job, heartbeats, invokes ffmpeg or poppler
-> Master Preview uploaded to the Media Store on R2
-> Job reaches ready with real width, height, and format
-> Application builds the public or private master URL from that descriptor
Control never authorizes end users and never mints delivery capabilities. The ready descriptor contains no URL, no locator, and no capability.
The interesting part of this repo is the reasoning as much as the code. Twenty-four architecture decision records cover the tradeoffs; the v1 contracts pin the wire behavior each consumer can depend on.
| Decision | Why it matters |
|---|---|
| Keep storage and authorization with applications | Why Shutter refuses to own uploads or users |
| Encrypt source capabilities | Locators are encrypted, not merely signed, so a URL never leaks a storage path |
| Authorize before private cache lookups | The ordering constraint that makes private caching safe |
| Separate source identity from location | An immutable Source ID drives cache and job identity while the locator stays replaceable |
| Require immutable source objects | Changed bytes are a different source, which removes cache invalidation as a category of bug |
A few properties fall out of those decisions:
- Fail-closed by construction. URLs and capabilities carry an explicit
v1version, so incompatible drift rejects rather than degrades. Missing configuration returns 401/503 on the affected route instead of booting a half-configured service. - Deterministic cache identity. Cache keys derive from a SHA-256 fingerprint
over
(protocol version, space, source id), so every component computes the same key without coordination. - Enforced runtime boundary.
scripts/check-edge-boundary.mjsfails the build if Worker code reaches for Node APIs; the Worker runs on Web standards only, with nonodejs_compat. - Conformance fixtures.
@shutter/testkitships versioned fixtures for capability encryption, URL construction, and normalization. Every consumer runs them against its own adapter.
| Path | What lives there |
|---|---|
apps/edge |
Cloudflare Worker: capability validation, private cache, R2 reads |
apps/control |
Control plane: job API, ledger, purge, imgproxy signing, admin API |
apps/admin |
Operator interface (TanStack Start); talks to Control's admin API |
apps/cli |
shutter, the admin API as a CLI for agents and scripts (Bun-compiled) |
apps/executor-video, apps/executor-pdf |
Isolated Master Preview executors |
packages/protocol |
Capability crypto, URL construction, cache identity |
packages/client |
Server-side client for consuming applications: capabilities, delivery URLs, Preview Jobs, purge |
packages/testkit |
Cross-consumer conformance fixtures |
packages/executor-runtime |
Shared claim, heartbeat, upload, and cleanup work cycle |
packages/observability-node |
Structured logging and OTLP export for Node services |
TypeScript workspace on Node 24 and pnpm 12.
pnpm install --frozen-lockfile
pnpm checkpnpm check runs formatting, the runtime-boundary lint, configuration tests,
TypeScript, the Node and workerd test suites, and every application build. The
Control tests start a throwaway Postgres through testcontainers, so pnpm test
needs a running container runtime (Docker, OrbStack, or Podman).
To run the services, copy .env.example to .env (and
apps/edge/.dev.vars.example to apps/edge/.dev.vars) and fill in what you
need. Everything is fail-closed, so you can enable one capability at a time. The
video Executor needs ffmpeg on PATH; the PDF Executor also needs
poppler-utils.
pnpm --filter @shutter/control db:migrate # Space Registry and Preview Job ledger
pnpm --filter @shutter/control dev # Control plane on :3000docs/development.md covers running each service, the conventions the build enforces, and a known version gap that stops the edge dev server from booting.
Spaces and deployment values
Space policies, API tokens, and Capability Keys are records in Postgres. Manage
them in the admin application (apps/admin, deployed as Shutter-Admin); they
are not deployment variables or checked-in tenant configuration. The Space administration
runbook covers creation, policy
changes, credential rotation, decommissioning, and imgproxy allowlist updates.
Railway domains, regions, repository and storage names, and the imgproxy source
allowlist are deployment inputs; the Worker's name, domain, and R2 binding are
its committed wrangler.jsonc; credentials are never source. The
self-hosting runbook describes the input
file, the reviewed plan, and live acceptance checks.
MIT. See LICENSE.
