System architecture

How Postila is built

A control plane on an origin server, a delivery data plane on the Cloudflare edge, and object storage that both talk to — with file bytes never passing through the origin in either direction. This page explains the request lifecycle, the caching layers, and the reasoning behind each decision.

Three planes, one source of truth

The system is split by what a component is responsible for, not by feature area. There are three planes:

  • The control plane — an origin server running the API. It owns everything that is a decision rather than a byte: authentication, accounts, plans and entitlements, billing, publishable keys and their signing secrets, the admin and operator consoles, usage dashboards, and the asynchronous processing queue. It is the only writer to the database.
  • The data plane — a Cloudflare Worker running in every Cloudflare data centre. It owns exactly one job: turning a delivery URL into bytes, safely. Authorization, image transformation, caching, and per-request usage metering.
  • Storage — an S3-compatible object store (Cloudflare R2 in production). Both planes read from it; only uploads write to it, and they write directly from the browser.

Both planes read the same database. There is no synchronisation pipeline, no eventual-consistency reconciliation between two stores, and no second copy of the access-control rules to drift out of date.

Why it works this way

The obvious alternative — one server doing everything — fails on two axes at once. Every protected image would stream through that server, so it pays bandwidth costs on bytes it merely relays, and its throughput ceiling becomes the product's throughput ceiling. Meanwhile the same server is handling Stripe webhooks and session logins, so a traffic spike on images degrades billing.

Splitting on responsibility rather than feature means the byte path can be optimised for latency and cost without touching anything transactional, and the control plane can stay a conventional, boring, easy-to-reason-about server.

System diagram

                        ┌──────────────────────────┐
                        │   Your app (browser)     │
                        │   @ibanzajoe/uploader    │
                        └────┬────────────────┬────┘
        upload (control)     │                │     deliver (bytes)
     presign / confirm       │                │
                             ▼                ▼
        ┌────────────────────────────┐   ┌─────────────────────────────────┐
        │  CONTROL PLANE  (origin)   │   │  DATA PLANE  (Cloudflare edge)  │
        │  api.postila.app           │   │  edge.postila.app               │
        │                            │   │                                 │
        │  • auth, sessions, admin   │   │  • delivery guard               │
        │  • plans, billing, Stripe  │   │  • image transformations        │
        │  • presign / confirm       │   │  • edge byte cache              │
        │  • usage dashboards        │   │  • persisted derivatives        │
        │  • async processing queue  │   │  • usage metering (analytics)   │
        └───────┬────────────────────┘   └────┬───────────────────┬────────┘
                │                             │                   │
                │        ┌────────────────────┘                   │
                ▼        ▼                                        ▼
        ┌──────────────────────┐                      ┌────────────────────┐
        │      Postgres        │                      │   Object storage   │
        │  accounts · plans    │                      │   Cloudflare R2    │
        │  api_keys · files    │◀─ usage rollup (cron)│                    │
        │  usage_events        │   from edge analytics│   originals +      │
        └──────────────────────┘                      │   derivatives      │
                                                      └────────────────────┘

  Bytes never cross between planes. Uploads go browser → storage directly.
  Delivery goes storage → edge → browser. The origin server is never in the
  byte path for either.

The important property is the one at the bottom: file bytes never transit the origin server. Uploads go from the browser straight to storage using a presigned URL. Delivery goes from storage to the edge to the browser. The origin only ever exchanges small JSON payloads.

Upload lifecycle

Uploading is a three-step handshake. The two API calls are control-plane operations carrying only JSON; the actual bytes move in the middle step, directly to storage.

1.  POST /api/uploads/presign          browser  →  control plane
    ├─ resolve the account from the publishable key
    ├─ check the plan's storage + file-size limits
    ├─ derive the storage key SERVER-SIDE (never client-supplied)
    └─ reserve a pending row, return a presigned PUT URL

2.  PUT <presigned url>               browser  →  object storage  (DIRECT)
    └─ the file bytes never touch our servers

3.  POST /api/uploads/confirm         browser  →  control plane
    ├─ promote the pending row to ready
    ├─ record the storage delta against the account's quota
    ├─ attach any per-file delivery protection chosen at upload
    └─ enqueue async processing (metadata, AV, moderation, OCR, video)

    → returns { handle, url } — the handle is the file's public identity

Why it works this way

The storage key is derived entirely on the server from the account the publishable key resolves to. A client cannot propose, influence, or observe where its bytes land. If it could, one tenant could name a key inside another tenant's namespace and overwrite their files — the presigned URL would happily authorize it, because a presigned URL authorizes whatever it was signed for.

The reserve-then-promote pattern (a pending row created at presign, promoted at confirm) exists because the upload can fail between steps 2 and 3. A pending row that is never confirmed is reaped; it never counts against quota and is never deliverable.

The key in step 1 is a publishable key, and the word is chosen deliberately. It is an identifier — it says which account an upload belongs to — not a credential, in exactly the sense a Stripe publishable key or a Maps key is not one. It ships in client code and anyone can read it. Where uploads must also be authorized, the account can require a short-lived HMAC-signed policy minted by the customer's own backend, which is what carries the authorization. The key secret that signs those policies never leaves the server.

Very large files fall back to a proxied multipart upload through the control plane — the one case where file bytes do transit the origin. Everything else is direct.

Delivery lifecycle

Every delivery request runs the same ordered pipeline at the edge, whether it is for an original or a transformed derivative:

GET https://edge.postila.app/<transform-chain>/<handle>

  1. parse the transform chain          400 if it isn't valid grammar
  2. resolve the file                   404 if unknown or deleted
  3. resolve the OWNER's account        never the caller's
  4. run the delivery guard             403 unless the request is authorized
  5. status check                       403 if quarantined / flagged
  6. consult the edge byte cache        HIT → serve (guard already passed)
  7. consult persisted derivatives      HIT → one storage read, no render
  8. on a full miss: render ONCE        then persist, cache, and serve

  Steps 2–3 are the only ones that can touch the database, and they are
  read-through cached. Steps 4–5 run on EVERY request — including cache
  hits — so a cached image is never reachable without re-authorization.

Note the ordering of steps 4 and 6. The cache is consulted after authorization, never instead of it.

Why it works this way

This is what makes it safe to cache protected bytes at all. A naive implementation either caches protected content and hopes the URL stays secret, or refuses to cache it and pays a full round trip on every view. Running a cheap authorization check on every request — and an expensive byte fetch only on a miss — gets both properties: cached bytes are never reachable without passing the guard, and a repeat viewer gets a cache hit.

Step 3 is a security boundary. The owner's account is resolved from the file, never from anything the caller supplied. Resolving it from the request would let a caller present their own credentials to unlock someone else's file.

The delivery guard

Every file resolves to one of three protection modes. A per-file setting wins if present; otherwise the file inherits the account default. The mode determines both who may fetch it and who may cache it:

public    serve to anyone.
          Cache-Control: public, max-age=31536000, immutable

hotlink   the request's Origin/Referer must exactly match an allowed origin
          (scheme + host + port — never a suffix match, which would let
          evil-example.com through for example.com).
          Cache-Control: private, max-age=86400, immutable

signed    the URL carries an HMAC-signed policy with an expiry, verified
          against the FILE OWNER's key secret — never the caller's — and
          bound to the specific handle being requested.
          Cache-Control: private, max-age=<until the policy expires>, immutable

Every Cache-Control above is what the CLIENT is sent. The copy the edge keeps
for itself carries a different, longer-lived header — see "Four caching
layers" for why the two are deliberately not the same value.

A signed URL is a capability: it carries its own authorization and its own expiry, so it can be handed to any client without a session. The signature is verified against the file owner's key secret and bound to the requested handle, so a signature minted for one file cannot be replayed against another — or against another tenant's file.

Why it works this way

Protected responses are marked private rather than no-store. no-store keeps bytes out of the requesting browser's own cache — which buys nothing, because that browser was already authorized and already has the bytes — while forcing a full network round trip on every subsequent view. private keeps protected bytes out of every shared cache, which is the property that actually matters, and lets repeat views cost nothing.

A signed response's cache lifetime is bounded by the policy's own expiry, so a cached copy dies with the capability that fetched it. The trade-off, stated plainly: a signed URL cannot be revoked mid-flight for a browser that already fetched it. That is the same model as presigned URLs everywhere else in the industry — control the exposure window with the expiry, not by disabling caching.

Hotlink protection compares the request origin exactly, on scheme, host, and port. It is a deterrent rather than strong authentication — the browser supplies that header, so a non-browser client can send anything. Use signed URLs where the guarantee needs to hold against a determined caller.

Four caching layers

Four independent caches sit in front of a delivery request. They have different scopes, which is the detail most worth internalising — "it's cached" means something different at each layer:

┌─ 1. BROWSER ─────────────────────────────────────────────────────────┐
│  Scope: one user, one device.        Cost on hit: 0 ms, no network.  │
│  Protected files are `private` (never a shared cache) but ARE        │
│  cacheable by the requesting browser, bounded by the signed policy's │
│  own expiry. This is the single biggest win for real page loads.     │
└──────────────────────────────────────────────────────────────────────┘
┌─ 2. EDGE BYTE CACHE ─────────────────────────────────────────────────┐
│  Scope: one Cloudflare data centre.  Cost on hit: ~10 ms.            │
│  Keyed on handle + normalized transform chain, deliberately          │
│  EXCLUDING the signature — so every valid signed request for the     │
│  same derivative shares ONE entry. Read only after the guard passes. │
│  Stored under a long-lived header of its own, NOT the one the client │
│  is sent — the two are re-derived separately on every path.          │
└──────────────────────────────────────────────────────────────────────┘
┌─ 3. PERSISTED DERIVATIVES ───────────────────────────────────────────┐
│  Scope: global, one copy.            Cost on hit: one storage read.  │
│  Every rendered derivative is written back to object storage, so a   │
│  data centre that has never served it fetches finished bytes instead │
│  of re-rendering them. Only the FIRST request anywhere pays a render.│
└──────────────────────────────────────────────────────────────────────┘
┌─ 4. EDGE METADATA CACHE ─────────────────────────────────────────────┐
│  Scope: global, replicated.          Cost on hit: ~15 ms.            │
│  The file row + the owner's delivery policy and key secrets. Without │
│  it, every request would cross a continent to Postgres.              │
└──────────────────────────────────────────────────────────────────────┘

So the answer to "is this image fast?" depends on who is asking. The same user viewing it again pays nothing. A different user in the same region gets an edge hit. A user routed to a data centre that has never served this image gets the persisted copy back from storage — a read, not a render — and then that region is warm too. Only the very first request for a given variant, anywhere in the world, pays for the render.

Why it works this way

The header the edge stores is not the header the browser gets. This is the load-bearing detail of the whole caching story, and it is easy to misread as a leak. A shared cache — including the edge's own — will not store a response marked private or no-store; the attempt is simply refused. So a protected response cannot be both cacheable at the edge and correctly marked for the client with one value.

The two are therefore computed separately from the same resolved protection mode. The copy the edge keeps for itself is written under a long-lived, storable header and under an internal key that is not a reachable route — nothing serves it directly, and it is only ever read from a code path that has already run the delivery guard on that same request. The response handed to the caller is re-marked private on every path, hit or miss. Protected bytes are cached everywhere it is safe to cache them and nowhere it is not.

Layer 1 has a precondition worth stating, because it is easy to lose by accident: a browser only reuses a cached response if the URL is byte-identical. A signed URL embeds an absolute expiry, so deriving one from "now" on every render produces a different URL every time — and to a browser a different URL is a different image, which makes the fastest layer permanently unreachable. The SDK's signing helper therefore quantizes the expiry (since v1.5.0): it rounds up to the next stable-window boundary, defaulting to the lifetime you asked for, so repeated calls inside one window return the same URL. Rounding up means the URL is never valid for less than requested; pass stableWindow: 0 to opt out and get an exact expiry instead.

Why it works this way

The edge byte cache is keyed on the handle and the normalized transform chain, excluding the signature. Signatures differ per user and per minute; the bytes they authorize do not. Including the signature in the key would produce a fresh cache entry per viewer and a hit rate near zero, while caching exactly the same image thousands of times.

The metadata cache exists because the guard needs to know the file's protection mode and the owner's signing secrets before it can authorize anything — and the database is in one region while the edge is in hundreds. Caching that lookup is the difference between a request that crosses a continent and one that stays inside a data centre.

Measured latency

Real numbers from the production edge, so the layers above have weight rather than being an architecture diagram that asks to be believed:

Server-side time, TLS handshake excluded, measured from Los Angeles
against the production edge:

  network + edge floor (a no-op endpoint)          ~31 ms
  + metadata resolution and the delivery guard     ~15 ms
  + serving the cached image bytes                  ~9 ms
  ────────────────────────────────────────────────────────
  warm protected image, end to end                 ~55 ms

  repeat view by the same browser                    0 ms  (no network)

For comparison, the same request before the caching work: ~190 ms of
work above the network floor, on every single view, because protected
responses were marked no-store and the metadata was re-fetched from the
origin database roughly every 30 seconds.

The headline is the last line. A protected image and a public image now cost the same thing on a repeat view — nothing — which is what makes access control practical to turn on by default rather than a performance tax you ration.

Transformations

Transform operations are expressed in the URL path as a chain — resize, crop, rotate, quality, output — and are executed at the edge by Cloudflare's image pipeline. Operations that pipeline cannot express fall back to a one-time server-side render, whose result is then cached at the edge exactly like any other derivative.

Chains are canonicalised before they become a cache key, so two different spellings of the same transformation collapse to one stored derivative instead of two. They are also bounded: dimensions, total pixels, and the number of operations in a chain all have hard ceilings, and a chain that exceeds them is rejected with a 400 rather than silently clamped. Clamping would produce two URLs for one output and defeat the canonicalisation the cache depends on.

Rendered derivatives persist. Each one is written back to object storage alongside the original the first time it is produced, so it exists in exactly two places: the per-data-centre byte cache, and one global stored copy. A data centre that has never served a given variant reads the finished bytes instead of re-rendering them.

Why it works this way

Without that stored copy the byte cache is per-data-centre and nothing else is global, so every one of Cloudflare's hundreds of locations re-renders every derivative independently — the same image produced hundreds of times, each render costing a few hundred milliseconds on somebody's page load. Persisting collapses that to one render and N cheap reads. The trade is one object-storage write per unique variant, which is exactly why the chain bounds above have to exist.

Transformations are billed per unique transformation, and the edge caches the result — so the cost driver is the number of distinct image variants your frontend requests, not the number of times it requests them. Three canonical sizes across ten thousand images is a small, predictable bill. An arbitrary width computed from the viewport is not: every distinct pixel value is a new variant, a new cache entry, and a new billable render.

Pick a small set of sizes and reuse them. This is the single most effective cost lever in the system.

Invalidation

Because the edge caches both bytes and access-control metadata, the control plane has to tell it when something changes. It does so on an authenticated internal channel whenever a change affects delivery: a file is deleted, an account's protection mode or allowed origins change, or a publishable key and its secret are created or revoked.

Why it works this way

Early on, cache entries were given a very short lifetime, so anything the invalidation path missed corrected itself within seconds. That was comfortable but expensive — a short lifetime means constantly re-fetching unchanged data across a continent, which was the dominant source of delivery latency.

Lengthening it made the invalidation path load-bearing rather than a nice-to-have: a revoked key that is not actively purged stays valid at the edge until its entry expires. So the two properties are deliberately coupled — any write that affects delivery must invalidate, and the long lifetime is only a backstop for an invalidation that could not be delivered, never the primary mechanism.

This is the kind of coupling worth writing down, because it is invisible in the code: a future change that caches something new at the edge without adding an invalidation hook would not fail any test, and would not be noticeably wrong until someone revoked a key and it kept working.

File content itself never needs invalidating. A handle is immutable — re-uploading a file produces a new handle — which is why derivatives can be cached for a year without any risk of serving stale content.

Usage metering

Because delivery happens at the edge, the origin server never sees a delivery request — so metering has to happen at the edge too. Each request writes one data point into Cloudflare Analytics Engine: a fire-and-forget, batched write that is never awaited and adds nothing to the response. A scheduled rollup job reads those points back in aggregate and upserts them into the same usage tables the dashboards already read. Plan limits are evaluated against those rollups, so an account's metered position converges within minutes rather than instantaneously.

Why it works this way

It is deliberately not a per-request database write. Hundreds of data centres inserting a row per delivered image into a single-region database would put the origin database back in the byte path — the exact coupling the two-plane split exists to remove — and it would fail at precisely the moment traffic is most worth measuring. A time-series store designed for high-cardinality writes absorbs that; the relational database only ever sees the periodic aggregate.

Metering at the edge is more accurate than metering at an origin, not less. An origin server only sees requests that miss the CDN, so any origin-based figure systematically undercounts exactly the traffic that is working best. The edge runs on every request, including cache hits.

It also decouples what a customer is shown from what the platform pays. Delivered bandwidth is reported accurately against plan limits even though the underlying egress cost is essentially zero — which is why bandwidth allowances here can be generous, and why exceeding one warns rather than cuts off delivery.

Cost model

What actually costs money, per request:

  storage                    per GB-month
  bytes out of storage       $0.00   ← same provider as the edge
  bytes out to the browser   $0.00   ← Cloudflare does not bill egress
  edge invocations           fractions of a cent per million
  storage read operations    only on a cache miss
  image transformations      billed per UNIQUE transformation per month

So cost scales with the NUMBER OF REQUESTS and the number of distinct
image variants — not with gigabytes delivered. A widely-viewed image
costs approximately the same to serve as a rarely-viewed one.

This is the direct consequence of the architecture rather than a pricing decision. Keeping the origin out of the byte path removes bandwidth as a cost centre; keeping storage and edge on the same provider removes it again on the other side.

Why it works this way

It also explains an otherwise surprising property: a large video streamed by thousands of viewers costs almost nothing in bandwidth, but a page requesting hundreds of tiny distinct image variants can cost more than expected. The cost unit is the request and the variant, not the gigabyte. Optimise for fewer, reusable URLs — not for smaller files.

Trade-offs worth knowing

Every design buys something by giving something up. The honest list:

  • Signed URLs cannot be revoked mid-flight. A browser that already fetched one keeps its copy until the policy expires. Control the window with the expiry. The alternative — disabling browser caching — costs a full round trip on every view for a guarantee most applications do not need.
  • The first request for a variant is slower. The byte cache is per-data-centre, so a region that has never served an image still pays a storage read for the persisted copy — and the very first request anywhere pays a full render. Repeat viewers anywhere, and all subsequent viewers in that region, are fast.
  • Hotlink protection is a deterrent, not authentication. The origin header is browser-supplied. Where the guarantee must hold against a determined caller, use signed URLs.
  • Metadata changes are not instantaneous everywhere. Invalidation propagates to a data centre that has already cached an entry within about a minute. For access-control changes this is a deliberate, bounded window, not an unbounded one.
  • Both planes share one database. That is what removes an entire class of synchronisation bugs, and it also means the database is a shared dependency that has to be sized for edge concurrency, not just admin traffic.

Ready to integrate? The developer documentation covers installing the SDK, uploading files, building transform URLs, and choosing a protection mode per file.