• Aug 5, 2026
  • 4 min read

Retention as a KV TTL: Let the Platform Enforce Your Privacy Policy

My privacy policies say enquiries are kept “up to 24 months after our last exchange”. Writing that sentence is easy. Making it true usually means a cleanup job that someone has to write, schedule and keep running.

Cloudflare KV can do it instead: every value can carry an expiration, and KV deletes it on its own.

Expiration at write time

The forms Worker (see one forms Worker for every site) sets the TTL when it stores the lead:

const ttl = RETENTION_SECONDS[lead.kind];
await env.LEADS.put(key, JSON.stringify(lead), {
  ...(ttl ? { expirationTtl: ttl } : {}),
  metadata: { site: lead.site, kind: lead.kind, email: lead.email /* ... */ },
});

No cron, no “delete where older than” query, nothing to forget.

One TTL for everything was wrong

My first version gave every lead 730 days. Adding a quarterly newsletter on fossengineer.com made the gap obvious: a subscription is valid until the person withdraws consent. A 24-month TTL would have silently dropped every subscriber after two years, without anyone unsubscribing.

So retention became a property of the form kind:

export const RETENTION_SECONDS: Record<Kind, number | undefined> = {
  enquiry: 730 * 24 * 60 * 60,
  ebook: 730 * 24 * 60 * 60,
  newsletter: undefined, // kept until the person unsubscribes
};
KindKV expirationPrivacy-policy wording
enquiry730 daysUp to 24 months after our last exchange
ebook730 daysUp to 24 months
newsletternoneUntil you unsubscribe

The rule behind it: retention follows the legal basis. An enquiry is kept to answer and follow up on a request, so it has an end date. A subscription rests on consent, so it ends when consent ends. I recorded the decision in an ADR and updated the privacy pages in the same change; a test asserts that newsletter leads are stored without expiry.

A subscription without expiry means unsubscribes must actually delete or suppress — which is what the double opt-in and suppression work later in this series is for.

Gotcha: re-putting a value resets its TTL

KV has no “update this field” operation. Changing a lead means writing the whole value again, and a new put sets a new expiration — or none, if you don’t pass one.

That happens in my Worker when someone confirms a subscription: the lead is rewritten with status: "confirmed". The code re-applies the kind’s retention on every write, so a value never loses its TTL by accident. For newsletters nothing changes (no expiry). For a kind with a TTL, a rewrite extends its life from the time of the write — something to keep in mind if you ever update enquiries.

The safe pattern is one helper that every write goes through:

const putLead = (env: Env, key: string, lead: Lead) => {
  const ttl = RETENTION_SECONDS[lead.kind];
  return env.LEADS.put(key, JSON.stringify(lead), {
    ...(ttl ? { expirationTtl: ttl } : {}),
    metadata: leadMetadata(lead),
  });
};

The copy must expire too

KV is my inbox, but I also mirror it into SQLite on my homelab as a backup. A backup that keeps leads after KV expired them quietly breaks the 24 months the policy promises.

So the mirror treats KV as the source of truth, retention included:

  • A lead that disappears from KV (expired or deleted) is marked as gone and purged locally after a 30-day grace period.
  • Independently, anything older than its kind’s retention is purged locally even if KV still had it.
  • Deleted rows are overwritten (PRAGMA secure_delete=ON), and dated backup files rotate out after 14 days.

The grace period exists for a different failure: if a pull ever sees a wrong or empty namespace, I don’t want it to wipe the mirror. A separate brake refuses to mark more than half the known leads as gone in a single run.

Takeaways

  • Put retention where the data lives: a KV expirationTtl set at write time enforces the policy without a job to maintain.
  • Retention follows the legal basis — enquiries expire, subscriptions last until withdrawn.
  • Route every write through one helper so a rewrite never drops the TTL.
  • Backups and mirrors are copies of personal data: they need the same retention, plus a brake against mass deletion.

Next: self-hosting ntfy behind a Cloudflare Tunnel.