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.
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
- Audit referential integrity. Query the CMS for assets with zero active references across every locale, environment, and content type using
usage,references, orlinkedBymetadata endpoints. Cross-check candidates against frontend routing tables for hardcoded paths. - Track soft-delete state. Add a
retention_statusenum to the asset schema and move assets throughactive→marked_for_deletion→purged. The middle state gives editors a grace period to restore and prevents races during webhook execution. - 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. - Gate deletions on a reference counter. A serverless listener intercepts
asset.deleteandasset.updateevents and checks a Redis-backed counter before deleting. The counter decrements only on published reference removal, ignoring draft and preview states. - 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.
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.
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.
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.