Rebuilding Ephemera as a Zero-Knowledge Secret Sharing App
Ephemera v2 is a ground-up rebuild. Client-side AES-256-GCM encryption with keys the server never sees, a reveal gate that stops link previews from burning one-time secrets, real tests, and a migration path that upgraded the live database without downtime.
Six months ago I deployed Ephemera, a small app for sharing self-destructing text and links. Getting it running on Coolify taught me a lot about Docker networking. The app itself, though, was maybe 10% of a real product. This week I rebuilt the other 90%.
The result: Ephemera is now a zero-knowledge secret sharing app. Secrets are encrypted in the browser before upload, the server stores only ciphertext, and the decryption key travels in a part of the URL that browsers never send over the network. The code is on GitHub.

The honest audit
Before writing any code I read every file in the old repo and made a list of what was actually wrong. It was humbling:
- Secrets were stored in plaintext. The whole point of the app, and the
bodycolumn was just sitting there readable by anyone with database access. - Opening a link consumed a view. The view page was a server component that recorded the view during render. Paste a one-time link into Slack or iMessage and the link preview bot would silently burn it before the recipient ever clicked.
- The owner account had no password. First-run setup created an owner with a session cookie and nothing else. When that cookie expired after seven days, the owner was locked out of their own instance. There was a
loginWithPasswordprocedure on the backend and no login page anywhere in the UI. - "Ephemeral" data lived forever. Nothing ever deleted expired content from Postgres. Expired meant hidden, not gone.
- Zero tests, no CI, no migrations. The schema was force-pushed at container start,
nextwas pinned tolatest, and debug endpoints from old deployment sessions were still shipping to production.
Each of these became a design constraint for v2.
Zero-knowledge encryption with WebCrypto
The centerpiece is client-side encryption. When you create a drop, the browser generates a random 256-bit key, seals the content into an envelope (type, body, filename for file drops), and encrypts it with AES-256-GCM via the WebCrypto API. The server receives ciphertext and an IV. That's all it ever sees.
The key rides in the URL fragment:
https://ephemera.example.com/d/50fd036ccaf09276#k=8fPqK3vN...
└── sent to server ┘└── never sent ──┘
Fragments are stripped by browsers before the request goes out, so the server can serve the page for a drop it cannot read. The one link you get at creation time is the only copy of the key in existence. The dashboard can't show it again, which is not a limitation but the security model working as intended.
For higher-stakes secrets there's a passphrase mode: the key is derived with PBKDF2-SHA256 at 600k iterations, only the random salt is stored, and you share the passphrase over a different channel than the link.
The threat-model note in the README is honest about the limits: browser-delivered E2E protects you from the database (backups, disk access, a curious host). A malicious server operator could still ship malicious JavaScript. That's exactly why it's self-hosted.
The reveal gate
Fixing the link-preview problem changed the whole viewing flow. Loading /d/<token> now renders a gate page from metadata only. Nothing is consumed. Bots, mail scanners, and prefetchers can hammer it all day.

Only an explicit click on "Reveal" calls the consume mutation, which atomically increments the view counter and returns the ciphertext. Decryption happens locally. A nice consequence of that split: if you typo a passphrase, the retry decrypts the ciphertext already sitting in memory. Wrong guesses don't burn extra views.
The Playwright suite proves this behavior end to end: one test loads a one-view link in a fresh browser context (simulating a preview bot), asserts the dashboard still shows 0/1 views, then reveals it for real and asserts the next visitor gets the tombstone page.
Making "ephemeral" true at the storage layer
A retention sweep now runs hourly inside the app (wired through Next's instrumentation.ts). Drops that have been dead for longer than the retention window (default three days) get their ciphertext blanked in place, keeping the metadata so your dashboard history still makes sense. View logs are pruned after 30 days, and viewer IPs are truncated before they're ever written (IPv4 to /24, IPv6 to /48). Expired means gone.
The parts nobody screenshots
Migrations for a database that never had them. The live database was built with drizzle-kit push, so it had all the tables and no migration history. Running a migrator against it would try to recreate everything and die. The fix: generate a baseline migration from the old schema, and teach the migration runner to detect the legacy case (tables exist, no history table) and stamp the baseline as already applied, using the same sha256-of-file bookkeeping drizzle's own migrator writes. Fresh databases replay everything; the production database got exactly one ALTER TABLE batch. I tested all three paths (fresh, legacy, re-run) against real Postgres before pushing.
One wrinkle: Next's standalone output only traces modules your app actually imports, and drizzle's migrator isn't one of them. Rather than fatten the image, the runner uses only pg (which is traced) and applies the SQL itself. It's 160 lines and it removed drizzle-kit from the production image entirely.
Testing routers against real SQL without Docker. The 50 unit tests run every tRPC router against PGlite, an in-memory Postgres, with the actual migration files applied. No mocks, no schema drift, and CI needs no database service for them. Auth flows, permission scoping, the consume race, purge behavior: all tested against the same SQL that runs in production.
Three bugs the tests caught before production did. A stale next.config.js was silently shadowing my new next.config.ts, which meant every security header I thought I'd added (CSP included) was never being sent. My CSRF origin check compared against req.url, which Next normalizes internally, so legitimate same-origin requests got rejected; comparing against the Host/X-Forwarded-Host header fixed it. And Turbopack's dev runtime needs 'unsafe-eval' in the CSP while production doesn't, which is the kind of thing you only learn when your e2e browser sits on a skeleton screen forever.
Everything else that changed
The upgrade list, since the stack had drifted: Next 15 to 16 (Turbopack builds), React 18 to 19, tRPC classic React Query bindings to the new TanStack integration, Zod 3 to 4, Zustand 4 to 5, plus ESLint 9 flat config, Prettier, Vitest 4, Playwright, and a GitHub Actions pipeline (lint, typecheck, unit, e2e against a Postgres service, Docker build).
The product grew a real login page, an admin panel (roles, session revocation, invite management), encrypted file drops, markdown rendering with syntax highlighting for revealed secrets, QR codes for share links, and a per-drop analytics page with a views-per-minute chart.

The UI got a full redesign in strict black and white: true-black surfaces, white ink, and primary actions that invert (white button, black text). Color survives in exactly one place, the status badges, because active/expired/exhausted/revoked is state worth encoding. Everything decorative is monochrome, down to a grayscale syntax highlighting theme for revealed code.

Numbers
- 111 files changed, roughly 16,000 lines added
- 50 unit tests against in-memory Postgres, 6 end-to-end browser flows
- 2 migrations, 1 of them stamped retroactively onto a live database
- 0 plaintext secrets at rest
The first deployment post was about getting a container to boot. This one is about what it takes to make a small app trustworthy: a threat model, tests that prove the guarantees, and a migration story for the database you already have. Same project, very different questions.