Data Retention Policies for Headless CMS Media

A retention policy for headless media has to delete the binary, not just the content reference to it. The CMS removes a reference; the file lives on in object storage and the CDN edge, often still publicly reachable past its regulatory retention window. This guide, part of Enterprise CMS Governance & Compliance, covers how to enforce TTLs, purge orphaned binaries, and sync CMS metadata with storage lifecycle rules without cache stampedes or broken builds.

The life of a media asset under a retention policyAn asset is active while referenced, becomes marked for deletion when its retention window ends, stays in a thirty-day grace period during which editors can restore it, and is then purged from storage and the CDN, with the purge recorded.Active, referencedretention windowMarked for deletiongrace periodPurgedstorage + CDN0 days100 days200 days300 days400 dayswindow endspurge
The grace period prevents races with in-flight edits and gives editors a chance to object.

Why retention logic fails

Headless stacks separate content delivery from binary storage, and that gap is where retention breaks. When the CMS drops a media reference over GraphQL or REST, the underlying file persists — the API has no native hook into the storage provider. Draft states, localized variants, and webhook-driven image transforms create phantom references that standard garbage collection misses.

The CDN compounds it: edge nodes keep stale binaries until an explicit purge, so an asset stays publicly accessible after the retention window expires even though it’s logically deleted. And because the build resolves asset URLs before the storage lifecycle completes, deleted media reappears in static exports or ISR caches — a race condition. Without explicit state tracking, retention stays reactive instead of automated.

Resolution

  1. Audit referential integrity. Query the CMS for assets with zero active references across every locale, environment, and content type using usage, references, or linkedBy metadata endpoints. Cross-check candidates against frontend routing tables for hardcoded paths.
  2. Track soft-delete state. Add a retention_status enum to the asset schema and move assets through activemarked_for_deletionpurged. The middle state gives editors a grace period to restore and prevents races during webhook execution.
  3. Map retention windows to storage lifecycle rules. Use prefix tagging (cms-asset-id/{id}/) or custom metadata headers so expiration rules don’t touch shared CDN paths or public buckets. See S3 lifecycle configuration to align triggers with your compliance calendar.
  4. Gate deletions on a reference counter. A serverless listener intercepts asset.delete and asset.update events and checks a Redis-backed counter before deleting. The counter decrements only on published reference removal, ignoring draft and preview states.
  5. Purge by content hash, not path. Issue targeted CDN purges keyed on the asset’s immutable hash, and update ISR tags or Vercel/Netlify cache headers so stale media can’t hydrate mid-window. See Next.js caching and revalidation patterns.

Webhook handler

The handler runs a guarded pipeline: every gate must pass before the binary is removed and the edge purged.

Guarded deletion of a media binaryA marked-for-deletion event is checked for published and draft references and for a nonzero reference counter, deferring if either remains; otherwise the object is deleted from storage, the CDN is purged by content hash and the CMS status is set to purged.marked_fordeletionZero refs?published + draftDeferactive referencesCounter== 0?Defercounter > 0Deletefrom storagePurge CDNby hashCMS statuspurgednoyesnoyes
Every gate must pass before the binary is removed and the edge purged.

This handler verifies zero references across published and draft states, reads the distributed reference counter (which is maintained by the reference-change listener, not by this handler), deletes from storage, then purges the CDN. Verify the webhook signature before it runs, as in the other governance handlers.

TypeScript
import { Request, Response } from 'express';
import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { Redis } from 'ioredis';

// Configuration (inject via environment variables in production)
const CMS_GRAPHQL_ENDPOINT = process.env.CMS_GRAPHQL_URL!;
const CMS_API_TOKEN = process.env.CMS_API_TOKEN!;
const S3_BUCKET = process.env.S3_BUCKET!;
const CDN_PURGE_ENDPOINT = process.env.CDN_PURGE_URL!;
const CDN_API_KEY = process.env.CDN_API_KEY!;

const s3 = new S3Client({ region: 'us-east-1' });
const redis = new Redis(process.env.REDIS_URL!);

interface AssetPayload {
  id: string;
  url: string;
  hash: string;
  retention_status: 'active' | 'marked_for_deletion' | 'purged';
}

async function verifyZeroReferences(assetId: string): Promise<boolean> {
  const query = `
    query CheckReferences($id: ID!) {
      asset(id: $id) {
        _referencesCount
        _draftReferencesCount
      }
    }
  `;

  const res = await fetch(CMS_GRAPHQL_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${CMS_API_TOKEN}`
    },
    body: JSON.stringify({ query, variables: { id: assetId } })
  });

  const { data } = await res.json();
  return data?.asset?._referencesCount === 0 && data?.asset?._draftReferencesCount === 0;
}

async function purgeCDNAsset(hash: string): Promise<void> {
  await fetch(CDN_PURGE_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${CDN_API_KEY}`
    },
    body: JSON.stringify({ files: [`https://cdn.example.com/${hash}`] })
  });
}

export async function handleAssetRetention(req: Request, res: Response) {
  const payload = req.body as AssetPayload;
  
  if (!payload?.id) return res.status(400).json({ error: 'Invalid retention payload' });
  // Other status changes are normal events for this webhook: acknowledge them so the CMS does not retry.
  if (payload.retention_status !== 'marked_for_deletion') return res.status(200).json({ status: 'ignored' });

  try {
    // 1. Verify referential integrity
    const isOrphaned = await verifyZeroReferences(payload.id);
    if (!isOrphaned) {
      return res.status(200).json({ status: 'deferred', reason: 'Active references exist' });
    }

    // 2. Read the reference counter; a read keeps retries idempotent, unlike a decrement here.
    const refCount = Number((await redis.get(`asset:refs:${payload.id}`)) ?? 0);
    if (refCount > 0) {
      return res.status(200).json({ status: 'deferred', reason: 'Redis counter > 0' });
    }

    // 3. Execute storage deletion
    await s3.send(new DeleteObjectCommand({
      Bucket: S3_BUCKET,
      Key: `assets/${payload.id}/${payload.hash}`
    }));

    // 4. Invalidate CDN edge cache
    await purgeCDNAsset(payload.hash);

    // 5. Update CMS state to purged (optimistic UI sync)
    await fetch(CMS_GRAPHQL_ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${CMS_API_TOKEN}`
      },
      body: JSON.stringify({
        query: `mutation UpdateStatus($id: ID!) { updateAsset(id: $id, data: { retention_status: "purged" }) { id } }`,
        variables: { id: payload.id }
      })
    });

    return res.status(200).json({ status: 'purged', assetId: payload.id });
  } catch (error) {
    console.error('Retention pipeline failed:', error);
    return res.status(500).json({ error: 'Retention execution failed' });
  }
}

Operational notes

Make webhook handlers idempotent and route failed deletions to a dead-letter queue. Storage providers rate-limit lifecycle transitions, so batch orphaned assets during off-peak windows to avoid throttling.

For enterprise deployments, align TTLs with legal-hold requirements and audit logging as covered in Enterprise CMS Governance & Compliance. When picking a platform, confirm it exposes granular asset lifecycle hooks and webhook payload filtering — see Headless CMS Architecture & Platform Selection.

Monitor orphan-detection latency, CDN purge success rate, and storage cost reduction, and alert on webhook failures and reference-counter drift.

Configuration Reference

Setting Typical value Why
Retention window per asset class, from policy Contracts, licences and privacy law differ by asset type.
Grace period 30 days Time to restore mistakes before binaries disappear.
Storage lifecycle rule on the asset’s own prefix only Never expire shared paths or derived renditions by accident.
CDN purge by content hash, verified Removes every rendition and edge copy.
Legal hold flag checked before deletion Litigation holds override retention.
Evidence purge report with counts and ids What auditors ask for.

Gotchas & Edge Cases

  • Image transformations. Resized and cropped renditions produced by an image service are cached separately under their own URLs. Purge by a tag or hash shared by all renditions, not only the original URL.
  • Licensed media. Stock images often have licence end dates rather than retention periods. Model the end date on the asset, and treat expiry like retention with a report to editors of pages that will lose images.
  • Environments. Branch environments and datasets copied from production reference the same assets. Count references across environments, or deletion will break staging.
  • Hardcoded URLs. Asset URLs pasted into rich text or code are invisible to reference counts. Scan rich text fields and the frontend repository for asset URLs during the audit.

Worked Example

An insurance company had to delete claim-related images after seven years and licensed campaign imagery at licence expiry. An audit found 14,000 binaries in storage with no CMS reference at all, many older than seven years and still publicly reachable through the CDN. The team added the retention status field, a nightly job marking expired assets, the guarded handler and a monthly purge report. The first run removed the backlog over two weeks in batches, and the monthly report became the evidence reviewed by the compliance team.

Publicly reachable assets past their retention dateThe number of media binaries still reachable through the CDN after their retention or licence end date, at the initial audit and after the first, second and third months of automated retention.Initial audit14200 assetsMonth 1310 assetsMonth 242 assetsMonth 337 assets
The backlog was cleared in the first month; afterwards only the grace period kept assets reachable.

Rollout Checklist

  • Classify assets by retention rule and record the rule on each asset.
  • Add a retention status with a grace period before purge.
  • Audit references across locales, environments, rich text and code.
  • Delete binaries only through the guarded handler, never by lifecycle rules on shared paths.
  • Purge every rendition from the CDN and verify.
  • Publish a monthly purge report with counts, ids and exceptions.

Frequently Asked Questions

Can storage lifecycle rules replace the handler?

For simple cases, such as deleting everything under a prefix after a fixed period, yes. They cannot check references, legal holds or CDN caches, so combine them with the handler for anything that is referenced from content.

What happens to pages that reference a deleted asset?

Nothing should, because the handler only deletes assets with zero references. For licence expiry, notify editors before the date so they can replace images.

How do we handle legal holds?

Add a hold flag or list that the handler checks before deleting, and record held assets in the purge report so the exception is visible.

Is deleting from the CMS media library enough?

Usually not. Many platforms keep binaries on their CDN for some time after deletion, and your own storage and caches may hold copies. Verify by requesting the URLs after deletion.

Who should approve the retention rules?

The policy owner, usually legal or privacy, approves the periods; the content operations team maps them to asset classes; engineering implements and reports. Record each approval, since the rules themselves are evidence.

Should retention apply to drafts and archived entries?

Yes. Drafts and archived entries still reference assets and still hold data. Count their references like published ones, and apply the same retention rules to the entries themselves.