Infrastructure · media

keepix.cloud — a media host that returns finished addresses

A media service for apps, storefronts and marketplaces: one signed request in — named WebP variants and an HLS ladder out, served from the client's own subdomain with a forever cache. Multi-tenant, metered billing down to hundredths of a cent, EXIF stripped on intake, and an erase-by-person endpoint that answers with a number. We designed and built the whole thing, from the intake pipeline to the dashboard.

Category
Infrastructure · media
Estimated cost
from €24,000
Timeline
≈ 10–14 weeks
keepix.cloudkeepix.cloud
Project homepage — keepix.cloud

01Project overview

keepix.cloud is a media host for products that have to serve other people's files: catalogue photos in a storefront, review pictures in a marketplace, walkthrough video in an app. You send the original in one signed request, and the answer already carries the finished addresses — a named set of WebP variants for an image, an HLS ladder for a video. The files are served from the client's own subdomain, so the reader sees their brand and never sees us.

The premise is that media is not a feature you build once per project. Every product that lets people upload something ends up writing the same thing again: resizing, format conversion, a naming scheme, a cache policy, metadata stripping, a delete-everything-about-this-person path. Written a fifth time, it is written badly a fifth time — and the parts that go wrong (a leaked GPS tag, an SVG that turned out to be a script) fail silently, without an error and without a log line.

So we built the service around one idea: the address is declared by the service and stored by the client, never computed on the client's side. That single decision removes the most common way media integrations break — a page that renders perfectly with a hole where the product photo should be.

We designed and built the whole product: the intake pipeline, the variant model, the video ladder, the multi-tenant model with its own domains and signing keys, the metered billing, the dashboard, the public API contract and the documentation. Below is what problem it solves, how it is put together, and which engineering decisions we would make the same way again.

02Context and the problem

Storing an uploaded picture is easy. Running a media path that stays correct for years, under load, across several products and other people's files, is not. When we unpacked the task, the pitfalls fell out quickly.

  • Arbitrary widths are a trap. A URL like ?w=473 looks flexible and is in fact two problems at once: a cache that never warms up, because every page asks for a slightly different number, and a free way for anyone to occupy the processor by asking for a thousand widths in a row.
  • A computed address breaks silently. The rule "id → shard → path", wired into the client's code, survives exactly until the first layout change on the other side. Then the page still renders and the product simply has a hole where its photo was — no error, nothing in the log.
  • Metadata is a leak nobody notices. The GPS coordinates of someone's home sitting in the EXIF of a review photo raise no error, break no page and reach no monitoring. It is only a leak when someone looks.
  • SVG is not a picture. It is executable markup. Accepted as an image and served from the client's own domain, a script inside it runs on behalf of that domain.
  • Deletion has to be provable. A GDPR erase request answered by marking rows deleted while the bytes stay on disk is a deletion that did not happen.
  • Video is a set, not a file. One upload has to become a playable ladder, a poster frame and a fallback — and none of that can happen inside the request that accepted the file.
  • Billing has to be honest at small numbers. A gigabyte can cost less than a cent; rounding to whole cents eats the entire metered bill and makes the invoice a fiction.

There was one more requirement, and it shaped the whole design: the service must not go out into the internet at all. A media host that fetches an image from a third-party address on the client's behalf knows who asked for what and hands that knowledge to whichever CDN it used. keepix knows not one third-party address, and that is a property of the code, not a promise on the pricing page.

03Project goals

The task turned into a list of goals we held on to at every stage:

  • One request in, finished addresses out. The client sends the original and immediately gets back the addresses to store — no second call, no waiting for a job to finish for images.
  • A named, finite set of variants. Three widths for images and four forms for video, declared per kind of file, so the cache is warm and the processor is not for hire.
  • The client's own domain. Files served from img.theirdomain, with the service invisible in the address bar.
  • A cache that can be kept forever. The address carries a hash of the content, so replacing a file changes its address and reaches the reader at once.
  • Safety built into the path. Type determined by parsing, a virus scan that refuses rather than waves things through, re-encoding on every image, metadata stripped, SVG refused on both ends.
  • Provable erasure. One request by the client's own owner_ref, and an answer that names how many objects and files went.
  • Multi-tenancy with real isolation. Own domains, own kinds and variants, own ceilings, two signing keys so rotation is not downtime.
  • Metered billing you can check. Storage as an average rather than a peak, traffic as the bytes actually delivered, operations counted one by one — broken down by day in the dashboard.
  • An API that admits what it did. Every answer says what was made and what was skipped, and there is a dry reconciliation endpoint for checking a migration in batches.

04What we did

keepix breaks into a few clear pieces, each with one job:

  • Intake. One signed endpoint accepts the original with its kind, its key and the list of variants wanted. Images come back ready in the same answer; video goes into a queue and the answer says so.
  • The processing pipeline. Type detection, virus scan, re-encoding, metadata stripping, variant building — in a fixed order that cannot be skipped per request.
  • Delivery. A separate host with no sessions, no JavaScript and no Set-Cookie, serving files straight from disk with a year-long immutable cache.
  • The tenant API. Signed with HMAC-SHA256, with endpoints for resolving addresses, reconciling in batches, exporting an inventory, deleting an object and erasing everything belonging to a person.
  • The dashboard. Folders, drag-and-drop, search by title, description and tags — and usage broken down by day. It works fully without JavaScript; the script adds convenience, not the ability to work.
  • Billing. Packages plus metered overage, held in hundredths of a cent, with intake stopping rather than silently spending money on the free plan.
  • The stock. A marketplace layer where tenants can buy and sell their own work, sharing the same storage and delivery path.

Everything below is a closer look at the decisions inside those pieces — the ones that would be expensive to change later.

05Solution architecture

The service is three spaces with physically separate entry points, and that separation is the backbone of the whole design:

  • Public delivery — no API at all. The web server looks the file up on disk and serves it. Not found means 404, not "generate it on the fly". There is no executable code on that path, which is why the delivery host can carry default-src 'none'; sandbox and mean it.
  • The tenant's private API. Every request signed; the signature covers the method, the path with its query string, the timestamp and a hash of the body. Signing the path without the query would leave kind and key unsigned — swappable, and nobody would notice.
  • The admin API for the control panel, on its own entry point with its own credentials.

Answers are always JSON and always in English, with one wording per refusal code — so the same failure reads the same in every client's log, whatever language their product speaks.

Inside, storage splits into a cold tree holding originals, which is never served outward, and the served tree of variants. Keeping the original is what makes a new variant possible a year later without asking the client to upload anything again — and it is also why "we do not serve the file you sent" can be an absolute rule rather than a usually.

06The life of a file

A file goes through a fixed sequence, and the order is the point:

  • The type is determined by parsing — from the content, not from the extension and not from what the client says it is. The extension on the output file is the service's to assign.
  • The virus scan runs, and it can refuse. If the scanner is unreachable, intake answers 503 instead of letting the file through. Passing a file silently while the check is down is a lie about work done, and it is the kind of lie that only surfaces much later.
  • The image is re-encoded. Not one byte of the file that arrived is ever served back out.
  • Metadata does not survive the re-encoding. EXIF and GPS are gone because of how the path is built, not because whoever uploaded remembered to strip them.
  • The variants are built and their addresses are returned in the same answer.

SVG is neither accepted nor served — never, on the way in or on the way out. The check runs twice, on the declared content type and on the first bytes of the file, because for SVG a type sniffer will often just say "text". What is served is WebP, JPEG, PNG, MP4, an HLS playlist and its segments; everything else is a 415.

The intake is idempotent: the same sha256 under the same key does not redo the work and returns the same addresses. Retries after a broken connection are therefore free, which matters when the file is two gigabytes and the network is a phone.

07Named variants, not arbitrary widths

Images get three named widths — thumb 400, card 1000, full 1400, all WebP at quality 82 — and you ask for a variant by name. The set is finite and declared per kind of file. This is the decision the whole service leans on, and it removes a class of problems rather than a bug.

Two rules govern when a variant may legitimately not appear, and both exist to avoid lying to the reader:

  • Nothing is stretched. If the original is narrower than the variant's width, the variant stays at the original's size and the answer says so honestly — the w and h fields carry the real numbers. A blown-up copy looks like the service's fault, and it would be.
  • Identical copies are not stored twice. If two widths would produce the very same file, the second address is not created. Two byte-identical files at two addresses are two downloads of the same thing onto someone's phone.

Which means the answer has to be read, not assumed: you may ask for two variants and get one, with a skipped block explaining why. We made that a normal, expected answer rather than an edge case, because an integration written against the optimistic shape breaks on the first small image a user uploads.

Adding a new variant later does not require re-uploading anything: it is built from the original in the cold tree. That is the whole reason the original is kept.

08Video: an HLS ladder, a poster and a preview

One video upload becomes a set, because that is what a player actually needs: an HLS ladder of up to three quality steps (640, 1280 and 1920 px), a poster frame taken at 10% of the duration, a silent preview clip of the first seconds for hover and feed autoplay, and a progressive MP4 as a fallback for older clients.

A step wider than the original is not made, for the same reason a stretched image is not: an upscaled copy looks like the service's fault. The poster is taken at a tenth of the duration rather than at the first frame, because the first frame of a real video is very often black.

Video processing does not happen inside the request that accepted the file — it goes into a queue, and the answer says so. The intake response is honest about which parts are ready now and which are pending, so a client can store what it has and ask again later rather than block a user's upload screen on a transcode.

09Your own domain and a forever cache

Files are served from the tenant's own subdomain — img.theirdomain — which the tenant points at the service with an A record. The reader sees their brand and their address, and does not see keepix at all. On the free plan the domain is shared; both roads work the same way.

A tenant may connect as many domains as they like but exactly one canonical one, and that constraint is deliberate: with two canonical domains one file gets two addresses, which means two caches and the same bytes downloaded twice onto the same reader's phone.

Delivery headers are the same for every tenant and do not change: a year-long immutable cache, nosniff, default-src 'none'; sandbox, a permissive CORS policy for reading, and not a single Set-Cookie. The forever cache is safe precisely because the address carries a hash of the content: change the bytes and the address changes with them, so a replaced image reaches the reader immediately instead of waiting out a TTL.

And that is exactly why the address cannot be computed on the client's side. It is returned by the service, stored next to the record it belongs to, and asked for again through resolve if it is ever lost.

10Intake: type, virus scan, metadata

Everything on the service is a file that some person sent — a photo in a review, a walkthrough video. The intake rules are written for their sake rather than for form's sake, and each of them exists because the alternative fails quietly.

Determining the type by parsing rather than by extension is not pedantry: the extension is the one thing an uploader fully controls. Refusing when the scanner is down costs availability and buys the guarantee that "scanned" means scanned. Re-encoding every image means a crafted file never reaches a reader in its original form. Stripping metadata during that re-encode means it cannot be forgotten.

On the delivery side the guarantees are structural rather than procedural. The delivery host has no sessions, no JavaScript and no executable code on the path at all; content goes out with a content security policy that would stop a browser from running anything even if something dangerous had got in. Two independent layers, because the interesting failures are the ones where the first layer was misconfigured and nobody noticed.

The delivery log, with readers' addresses in it, lives 24 hours. Nothing in the service needs it for longer, which makes keeping it longer plain accumulation of other people's traces.

11Erasing everything about a person

Every object carries an owner_ref — whatever the tenant calls the owner of that file. For catalogue images it is empty: they are not about a person. For review photos and user video it is required.

One request erases everything under it, and the answer names a number: how many objects and how many files went. That number is the point of the endpoint. A personal-data gate should check execution, not intent — a row marked deleted while the bytes still lie on disk is a deletion that did not happen, and it will keep not happening until somebody looks.

Erasure removes records and bytes at once rather than on a schedule, and it takes the deferred copies of earlier versions with it. It is also the piece that makes keepix usable from a product with its own GDPR obligations: a delete flow in the client's code can call it inline and read the count back, instead of trusting that a cleanup job somewhere will get to it.

Signing keys work the same way. A tenant always has two in use, because with one key rotation means downtime — and downtime is why rotation gets postponed forever. The secret is shown once at issue and is never handed back to the control panel: being able to sign a request is the right to write into someone else's storage.

12A signed API and reconciliation

Every private request carries four headers — tenant, key id, timestamp and signature — and the signature is HMAC-SHA256 over the method, the full path with query, the timestamp and the hash of the body. The window is 300 seconds. An identical signed request repeated within the same second is refused as a replay, which is the protection working as intended, and the contract says so plainly instead of leaving integrators to discover it.

The endpoint set is small and each one answers a real operational question:

  • put — accept an original; put-variant — accept an already finished copy without re-encoding it, for moving over what a client already has. Re-encoding a finished copy means losing quality for nothing.
  • resolve and resolve-batch (up to 1000 keys) — what addresses this key has. This is a repair tool: if a record is lost you ask rather than guess.
  • reconcile — a dry check, up to 1000 records at a time, that changes nothing and answers ok / missing / mismatch / pending per record plus a digest of the batch.
  • inventory — the export that runs the comparison the other way, the service's list against the client's, paged by the key itself so the page does not slide while things are being accepted mid-walk.
  • object, erase, quota, ping, and an unsigned healthz that says the front end is alive and nothing more — no version, no database state.

Reconciliation deserves its own note, because it is the endpoint that justifies the whole contract. After a large migration there is exactly one question that matters: did it all get through. Answering it one file at a time means as many requests as there are files, so nobody does it — and then a migration is declared finished on the basis of the upload script not having crashed. Batched reconciliation makes the honest answer cheap enough to actually get.

13Dashboard, usage and billing

The dashboard is a library with no code at all: folders, dragging files with the mouse, search by title, description and tags. It works fully without JavaScript — the script adds convenience, not the ability to work. That is not nostalgia; it is the cheapest way to guarantee the panel stays usable on a bad connection and remains testable without a browser engine.

Usage is counted from the start of the month and broken down by day: how much storage, how much delivery traffic and how many operations fell on each date, how much of the package is eaten and what came on top. The breakdown matters more than the total — a bill you cannot attribute to a day is a bill you cannot argue with.

Three decisions in the billing are worth naming:

  • Storage is the average over the period, not the peak. A single day with a big upload should not price the whole month.
  • Delivery traffic is the bytes actually delivered, taken from the server log — not "file size × number of requests", which overstates every range request and every abandoned download.
  • Rates are held in hundredths of a cent. A gigabyte can cost less than a cent; rounding to whole cents would eat the entire metered bill and quietly turn the invoice into fiction.

On the free plan there is no metered billing at all: intake stops instead of silently starting to spend the client's money. A service that decides on its own to bill somebody who never entered a card is a service nobody recommends twice.

14Technology stack

We chose the stack for a service whose hot path is a web server handing over a file:

  • PHP 8.4 for the API and the control panel — mature, fast enough on this path and cheap to keep running, which matters for a product with a free tier.
  • A dedicated delivery host serving straight from disk, with no application code in the request path at all.
  • WebP for images at a fixed quality, H.264 + AAC in HLS for video with a progressive MP4 fallback.
  • A queue for video work, so a transcode never sits inside an upload request.
  • HMAC-SHA256 request signing with two live keys per tenant and a 300-second window.
  • A cold tree of originals outside the served path, which is what makes new variants and re-builds possible later.

The service is deliberately conservative in what it depends on. It makes no outbound requests of its own, uses no third-party CDN and holds no records about the client's users beyond tenant, key, kind, byte count and variants. Every dependency you do not have is a dependency that cannot break your media on a Tuesday.

15Design and UX

The public site had one job: make an infrastructure product legible to the engineer who will actually integrate it. So the first screen shows the real request and the real answer — the addresses coming back in JSON — rather than an abstraction of them. An integrator decides in about thirty seconds whether a media service fits their code, and they decide by looking at its response shape.

The rest of the site is written the same way. Each capability is stated together with its constraint: named variants and why arbitrary widths are refused; a forever cache and the hash in the address that makes it safe; an erase endpoint and the number it answers with. Documentation that only lists what a service can do leaves the reader to discover the edges in production.

In the dashboard the same principle turns into the no-JavaScript baseline and the day-by-day usage breakdown: nothing on screen that the reader cannot check, and nothing that stops working when a script fails to load.

16How we worked

The project ran in stages, each ending in something demonstrable:

  • Contract first. We wrote the API contract — signing scheme, endpoints, refusal codes, answer shapes — before the implementation, because the contract is the part clients build against and the part that is expensive to change.
  • Intake and variants. The pipeline, in its fixed order, with the two rules about not stretching and not storing duplicates built in from the start.
  • Delivery. The separate host, the header set, the content-hashed addressing that makes the forever cache safe.
  • Video. The ladder, poster and preview, and the queue that keeps transcoding out of the upload request.
  • Multi-tenancy. Domains, kinds, variants, ceilings, and the two-key rotation model.
  • Billing and dashboard. Metered counting with a day-by-day breakdown, then the library UI on top of it.
  • A real migration. We moved a live catalogue onto the service and reconciled it in batches — which is how the reconciliation endpoint earned its shape.

That last stage was worth more than any amount of internal testing. Moving hundreds of thousands of real files found the things a test suite does not: objects the service accepted and lost, records whose stored address had drifted, and the difference between a check that verifies hashes and a check that merely confirms a row exists. A verification with nothing left to verify reports success — so we made the weak mode say which mode it is in.

17The result

What came out is a media host that a product can lean on: one signed request in, finished addresses out, served from the client's domain with a cache that can be kept forever.

1 request
original in — finished addresses back
0
outbound requests: the service knows no third-party address
24 h
the delivery log with readers' addresses lives one day
  • Named variants and an HLS ladder, built once and cached forever behind a content-hashed address.
  • The client's own subdomain: the reader sees their brand, not ours.
  • Safety built into the intake path — parsed type, refusing virus scan, re-encoding, stripped metadata, SVG refused on both ends.
  • Erasure that answers with a number, so a personal-data gate can check execution rather than intent.
  • Multi-tenancy with own domains, own ceilings and two-key rotation without downtime.
  • Metered billing held in hundredths of a cent, broken down by day, and a free plan that stops rather than spends.

18Takeaways

keepix is a case where most of the engineering went into deciding what the service would refuse to do. No arbitrary widths, no computed addresses, no SVG, no outbound requests, no keeping the original bytes on the served path, no billing on the free plan. Each of those removes a whole class of failures instead of handling one.

The main lesson we would carry to the next infrastructure project: make the honest answer cheap to get. Reconciliation in batches, usage broken down by day, an erase that returns a count, an intake response that names what it skipped — in every case the alternative was not a wrong answer but no answer at all, and no answer is what teams quietly settle for.

If you need a media service under your own domain, a file pipeline that has to be safe with other people's uploads, or an API-first product where the contract matters more than the screens, this is the kind of work we do end to end.

Need a similar product?

Tell us about your task — we’ll propose architecture and an estimate. Free consultation.

What searches this page answers

media hosting service development, build your own image cdn, image resizing service development company, webp variants api, hls video hosting for apps, image hosting on your own domain, multi-tenant media storage development, signed upload api hmac, strip exif on upload, svg upload security risk, gdpr erase user files api, image cache immutable content hash, video transcoding pipeline development, user generated content moderation pipeline, metered billing saas development, keepix cloud, keepix reviews, keepix api docs, how much does it cost to build a media host, cdn alternative for marketplace images.