Luna: the multi-client CDN that serves this very page
May 29, 202610 min read
Every project I build eventually hits the same wall: something has a local file and something else needs a public URL. A backend generates a marketing image and the publishing API wants a link, not bytes. A chatbot receives a photo and the admin panel needs to render it. A video pipeline produces a clip and the front-end needs to stream it. I kept re-solving this — a bucket here, a folder served by nginx there, a Cloudinary trial that expired — until I stopped and built Luna, my own CDN.
Luna is a self-hosted media manager. You hand it a file over HTTP with an API key, it normalizes the format, generates the variants you'll need, stores everything under a per-client vault, and hands back an immutable public URL. The CDN that serves the screenshots in this portfolio is Luna. So is the one behind the social-media covers for a handful of businesses I run backends for.
The shape of it
upload (X-API-Key)
│
▼
┌──────────┐ image → WebP 2048² + 300² thumb
│ convert │ video → MP4 (H.264/AAC) + JPG thumb
│ (sharp/ │ svg / mp3 / lottie → passthrough
│ ffmpeg) │
└────┬─────┘
│
▼
per-client vault ──────► cdn.solutions45.com/{uuid}.{ext}
(keyed by the API key) (public, no auth, cached)
The stack is deliberately boring: Express 5, better-sqlite3 (synchronous, WAL), sharp for images, ffmpeg for video, vanilla JS for the admin UI. No build step. A single Node.js process behind Caddy, running as a systemd service — not even containerized, because it doesn't need to be. The whole thing is one long-lived process that has been up since I last deployed it, sitting at ~53 MB of resident memory.
The database is the source of truth, not the filesystem. Files live as flat UUID-named blobs under /srv/media/files; SQLite knows what each one is. That split matters later.
The vault is the key
The one idea that holds Luna together: every API key owns exactly one client vault. The key is the client. You never send a client_id in a request body — the X-API-Key header identifies who you are, and everything you upload, replace, or delete is scoped to your vault. An ownership check runs on every mutating route: the file's client_id must match the key's client_id, or you get a 403. One project cannot reach into another's media even by guessing UUIDs.
Keys are SHA-256 hashed at rest. They're high-entropy random strings, so no salt is needed — there's no dictionary attack to defend against. The database stores the hash plus the last four characters as a preview, and the raw key is shown exactly once, at creation. Lose it and you rotate it; there's no recovery, because Luna never had the plaintext to begin with. Revocation is a soft-delete (revoked_at timestamp), and every lookup filters on revoked_at IS NULL, so a revoked key is dead the instant you revoke it without disturbing the audit trail of who uploaded what.
Right now Luna runs 13 clients with 16 active keys across them — backends for taquerías, a real-estate poster, an auto-parts shop, an insurance agency. The discipline that keeps it sane is one rule: each client uses its own key, and keys are never lent across projects. A borrowed key uploads under the wrong vault, and untangling cross-contaminated media after the fact is the kind of cleanup that eats an afternoon.
What Luna does so the caller doesn't
The entire point is that the calling code stays dumb. It throws a file at an endpoint and gets back a URL. Everything between those two moments is Luna's job.
Images become WebP. Every image re-encodes to WebP at up to 2048×2048, plus a 300×300 thumbnail (-thumb.webp). The caller never thinks about format or sizing — it uploads a 4 MB PNG screenshot and gets back a tidy WebP plus a thumb it can use in a gallery grid.
Videos become MP4. H.264/AAC, with a 300-pixel-wide JPG thumbnail extracted by ffmpeg. If the upload is already a clean MP4, Luna keeps it; otherwise it transcodes.
A few formats pass through untouched — SVG, MP3, and .lottie files are served as-is, because re-encoding them would be either pointless or destructive.
This is why you read the cdn_url from the response and never infer the extension from your filename: you might upload hero.png and get back hero.webp. The database tracks what each file actually became — its real extension, MIME type, byte size, type bucket (image/video/audio/vector/lottie), and whether it has a thumbnail and a resized variant. As of today that's 280 files in the database, 588 physical files on disk (primaries plus all their variants), totaling 285 MB. About 252 of those are images, the rest a mix of video, audio, SVG, and Lottie.
Clients can also override the default storage policy. One of them — an auto-parts shop with a denser product gallery — gets an extra -md 800×800 variant and a cover-fit thumbnail instead of the default inside-fit, configured per client without touching the upload path.
Branded covers and the overlay that feeds the video pipeline
Some clients don't just want their photo hosted — they want it turned into a social-media asset with their branding baked in. Luna generates those server-side too. Four endpoints, four canvases: /story (1080×1920), /cover (1080×1350), /square and /fb (both 1080×1080). You point one at an existing file, and Luna composites the client's logo, a diagonal watermark, gradient overlays, and — for the real-estate client — bedroom/bathroom/area pills, then saves the result as a new file in the same vault.
Each client's branding lives in a registry keyed by client_id: which logo, which watermark text and font, which layout, which formats they're even allowed to generate. A real-estate poster gets the full composition with property-detail pills and a serif watermark. A taquería gets a minimal layout — just their logo tucked in a corner and a faint diagonal watermark — that preserves the source image's native dimensions, because their photos arrive pre-formatted and resizing them would do more harm than good.
There's a quieter sibling to the cover endpoints: /api/overlay/generate. Same input shape, but instead of compositing onto a photo and saving a file, it returns a transparent PNG directly and saves nothing. That PNG is the branding layer alone — logo, watermark, gradients on a clear background. My video service pulls it down and hands it to ffmpeg as an overlay track, so a video clip gets the exact same brand treatment as a still cover, composited at render time. One branding registry, two output paths: baked-into-an-image for stills, transparent-layer for motion.
Two janitors
A CDN accumulates cruft, so two background services run on intervals.
The log-scanner tails Caddy's CDN access log every five minutes, pulls the filenames out of successful GETs, and marks those rows as referenced in the database. It's how the admin gallery can show me which files are actually being hit in the wild versus which are dead weight I uploaded and forgot.
The file-expirer runs every fifteen minutes and is the muscle behind ephemeral clients. A client can be flagged is_ephemeral at creation — uploads to it skip all transformation (no WebP, no thumbnails, stored raw) and get hard-deleted after 24 hours. This is for scratch media: a video pipeline staging frames, a one-off test, anything that needs a public URL for a few hours and then should vanish. The expirer selects ephemeral files older than a day and runs them through the exact same delete path as a manual deletion — every variant off disk, the row out of the database — so there's only ever one code path for "remove a file," no special-casing.
Defense in depth at the edge
The public side, cdn.solutions45.com, is just a Caddy file_server pointed at the media directory — no application code in the hot path for reads, which is what keeps it fast. But "just a file server" is where CDNs get themselves owned, so Caddy does the guarding before a request ever touches the filesystem:
- Dangerous extensions are blocked at the vhost —
.html,.js,.pdf,.exeand friends never serve, so nobody turns my image host into a malware or phishing host. - Extension-less paths are rejected — a bare UUID with no suffix isn't a legitimate file in this CDN, so it's refused outright.
- SVGs are forced to download rather than render, with a
Content-Disposition: attachment, sandboxing any script an SVG might smuggle. - A short
max-age=300, must-revalidatecache keeps things fast while letting a replaced file propagate within minutes.
The admin API at luna.solutions45.com sits behind HTTP Basic auth, with a narrow matcher that lets only the X-API-Key upload/replace/delete/cover routes (and the public OpenAPI spec) bypass it. So even the management surface is defense-in-depth: the auth a key carries is enough to push media, and nothing more. The genuinely administrative actions — listing every client, minting and revoking keys, moving files between vaults — require a real session and are walled off behind Basic auth at the edge.
What I'd tell anyone building one
A few decisions paid off more than I expected.
Make the database the source of truth, not the disk. Because every file's identity lives in SQLite, "replace this image but keep its URL" is trivial — replace keeps the same UUID, re-encodes the bytes, and the public link never changes. That single property makes Luna safe to wire into anything: an undo flow, a CMS edit, a "the client sent a better photo" swap. The filesystem is just a bag of blobs.
Bind every credential to exactly one tenant. The key-is-the-vault model meant I never had to write authorization logic per route — the ownership check is one comparison, and it's the same comparison everywhere. Multi-tenancy got simple because I refused to let it get clever.
Push the cheap defenses to the edge. Letting Caddy block dangerous extensions and force SVG downloads means the application never has to think about those attacks. The fast path stays a dumb file server; the smart stuff happens at upload, once, server-side.
Luna isn't glamorous. It's a few thousand lines of Node doing unglamorous work so that none of my other projects ever have to think about media again. The proof is the page you're reading: every screenshot on this site came back from a cdn_url in a Luna response.