Cleaning Up Stale Preview Deployments
Per-pull-request previews are one of the best things about static hosting: every change gets a URL, reviewers see the rendered page, and nothing has to be merged to be seen. They also accumulate. A repository with a year of history can have several hundred live preview deployments, each a complete copy of the site at some past moment.
Most of the harm is invisible until it is not. Previews get crawled and indexed. They hold whatever configuration was current when they were built, including tokens that may since have been rotated for a reason. And on some hosts they count against storage or deployment quotas. This guide tears them down properly. It is part of Preview Environments for Pull Requests.
Prerequisites
- Preview deployments created per pull request, with a predictable naming scheme.
- API credentials that can list and delete deployments, scoped to the preview environment.
- The ability to set response headers on preview hostnames.
What Stale Previews Actually Cost
Keep Them Out of the Index From the Start
Cleanup is the second line of defence. The first is making sure a preview cannot be indexed while it lives, and that has to happen at the host or edge layer so it covers assets and error pages too:
# public/_headers — applied only on preview deployments
https://:project.preview.example.com/*
X-Robots-Tag: noindex, nofollow
Cache-Control: no-store
// Or at the edge, for hosts without a headers file
export default {
async fetch(request, env) {
const res = await env.ASSETS.fetch(request);
if (new URL(request.url).hostname.endsWith('.preview.example.com')) {
const out = new Response(res.body, res);
out.headers.set('x-robots-tag', 'noindex, nofollow');
return out;
}
return res;
},
};
Serve a blanket-disallow robots.txt on preview hostnames as well. The header is what actually keeps a page out of the index — robots.txt only prevents crawling, and a URL linked from elsewhere can still be indexed without being crawled — so use both.
Delete on Close, Not Only on Merge
The teardown trigger should be the pull request closing, whether it was merged or abandoned. Abandoned branches are the larger population on most repositories.
# .github/workflows/preview-cleanup.yml
name: Tear down preview
on:
pull_request:
types: [closed]
jobs:
teardown:
runs-on: ubuntu-latest
environment: { name: preview }
steps:
- uses: actions/checkout@v4
- name: Delete the preview deployment
run: node scripts/delete-preview.mjs "pr-${{ github.event.number }}"
env:
CF_API_TOKEN: ${{ secrets.CF_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Note it on the pull request
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner, repo: context.repo.repo,
body: 'Preview deployment removed.'
})
// scripts/delete-preview.mjs <alias>
const alias = process.argv[2];
const base = `https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/pages/projects/docs`;
const headers = { authorization: `Bearer ${process.env.CF_API_TOKEN}` };
const list = await (await fetch(`${base}/deployments?env=preview`, { headers })).json();
const targets = (list.result ?? []).filter((d) => d.deployment_trigger?.metadata?.branch === alias);
for (const d of targets) {
const res = await fetch(`${base}/deployments/${d.id}?force=true`, { method: 'DELETE', headers });
console.log(`${d.id} → ${res.status}`);
}
console.log(`deleted ${targets.length} deployment(s) for ${alias}`);
The comment on the pull request is not decoration: it tells anyone returning to an old thread that the link they are about to click is gone deliberately.
Sweep the Stragglers on a Schedule
Teardown fails sometimes — a rate limit, an API outage, a pull request closed while CI was disabled. A weekly sweep catches whatever the event-driven path missed:
// scripts/sweep-previews.mjs — delete previews older than 14 days with no open PR
const base = `https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/pages/projects/docs`;
const headers = { authorization: `Bearer ${process.env.CF_API_TOKEN}` };
const CUTOFF_DAYS = 14;
const openPrs = await (await fetch(
'https://api.github.com/repos/acme/docs/pulls?state=open&per_page=100',
{ headers: { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } })).json();
const openBranches = new Set(openPrs.map((pr) => `pr-${pr.number}`));
const deployments = (await (await fetch(`${base}/deployments?env=preview`, { headers })).json()).result ?? [];
const cutoffMs = CUTOFF_DAYS * 86400_000;
let deleted = 0;
for (const d of deployments) {
const branch = d.deployment_trigger?.metadata?.branch ?? '';
const ageMs = Date.now() - new Date(d.created_on).getTime();
if (openBranches.has(branch)) continue; // still under review
if (ageMs < cutoffMs) continue; // recently closed, keep briefly
await fetch(`${base}/deployments/${d.id}?force=true`, { method: 'DELETE', headers });
deleted++;
}
console.log(`swept ${deleted} stale preview(s) of ${deployments.length}`);
Two safety properties matter here. The sweep never deletes a preview whose pull request is open, no matter how old it is — long-running branches are a legitimate case. And it keeps recently closed previews for a fortnight, because a reviewer often revisits a link shortly after merge.
Access Control for Unreleased Content
For public documentation, noindex plus an unguessable hostname is proportionate. For content under embargo — a release announcement, a pricing change, an unannounced feature — a preview URL is a link that will eventually be pasted somewhere it should not be.
An access rule at the edge costs one configuration entry and removes the whole category of risk:
Application: *.preview.example.com
Policy: Allow — emails ending @example.com
Session: 24 hours
The tier also changes retention. An embargoed preview should be deleted the day the pull request closes rather than kept for a fortnight, because the link has already been shared with whoever needed it.
Measured Impact
A documentation repository with two years of history, before and after introducing teardown and a weekly sweep:
| Measure | Before | After |
|---|---|---|
| Live preview deployments | 412 | 6 (open PRs only) |
| Preview URLs in the search index | 37 | 0 (after removal requests) |
| Preview storage | 31 GB | 480 MB |
| Deployments listed by the API | 412 (slow paging) | 6 |
| Previews holding a rotated token | 118 | 0 |
The last row was the finding that made this a priority rather than housekeeping. A token had been rotated eight months earlier, and more than a hundred live deployments still held the old value in their build-time configuration — harmless because the token no longer worked, and an uncomfortable thing to explain in a review.
Removing What Is Already Indexed
If previews have been live and crawlable for a while, deleting them is necessary but not sufficient — the URLs may already be in a search index, and a deleted preview returns a 404 that takes weeks to clear on its own.
Three steps shorten that. Confirm the scope with a site query against the preview hostname, so you know how many URLs are involved rather than guessing. Submit a removal request for the hostname in your search console, which suppresses them within a day or two. And keep the noindex header serving on the preview hostname itself, so anything crawled during the cleanup window is dropped rather than re-added.
Do not redirect preview URLs to production equivalents. It looks helpful and it teaches the index that the preview hostname is a legitimate source of your content, which is the opposite of what you want. A 404 or a 410 is the correct response for a preview that no longer exists.
Pitfalls & Rollback
- Only tearing down on merge. Abandoned pull requests are the larger population and never merge.
- Relying on
robots.txtalone. It prevents crawling, not indexing. ServeX-Robots-Tag: noindexas well. - Sweeping by age alone. A long-lived branch under active review will be deleted mid-review. Check for an open pull request first.
- Deleting the production deployment. Filter deletions by environment explicitly; a script that lists all deployments and deletes old ones is one missing filter away from an outage.
- No feedback on the pull request. Reviewers returning to an old thread find a dead link and file a bug.
- Rollback: previews are disposable by construction — a deleted preview is recreated by pushing to the branch again. If a sweep is too aggressive, raise the cutoff; nothing is lost that a rebuild cannot recreate.
Conclusion
Treat previews as ephemeral in both directions: create them automatically, and remove them automatically. Serve noindex for as long as they live, delete on pull request close rather than merge, and run a weekly sweep that respects open branches for the cases where the event never arrived. The result is a handful of live previews that correspond exactly to work in progress, which is also the only state in which the preview list is useful. The creation side is in Preview Environments for Pull Requests.
FAQ
Why do stale previews matter if nobody visits them?
Search engines visit them. A preview URL that gets linked from a pull request or a chat message can be crawled and indexed, which puts duplicate content and unfinished pages into search results under a hostname you do not monitor.
Should previews be deleted on merge or on a schedule?
Both. Delete on merge or close, because that is when the preview stops being useful, and sweep on a schedule to catch the ones whose teardown failed or whose pull request was closed while CI was down.
How do I keep previews out of search results?
Serve X-Robots-Tag: noindex on every preview response and a robots.txt that disallows everything. Do it at the host or edge layer rather than in the markup, so it covers assets and any page the build did not produce.
Do previews need authentication?
For public documentation, usually not — noindex plus an unguessable hostname is enough. For unreleased content, yes: an access rule at the edge is cheap and removes the risk of an embargoed page being shared accidentally.
What is a reasonable retention period?
Delete on close, and sweep anything older than about two weeks regardless. Longer retention rarely helps, because a preview more than a fortnight old no longer matches the branch it came from.
Related
- Parent: Preview Environments for Pull Requests — how the previews are created.
- Automating Preview Deploy Pipelines With GitHub Actions — the workflow this attaches to.
- Setting Up Deploy Previews on Netlify for Every Pull Request — the same lifecycle on another host.
- Deploying to Multiple Environments From One Workflow — scoping preview credentials.
- Running Smoke Tests Against a Preview URL — what previews are for while they live.