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

Four costs of leaving previews live Four categories. Search: preview URLs get crawled and indexed, creating duplicate content under an unmonitored hostname. Secrets: an old preview holds the configuration current when it was built. Quota: deployments and storage accumulate against host limits. Confusion: a stale preview URL shared in chat outlives the branch and misleads readers. The costs nobody notices until an audit Indexed by search engines duplicate content, unfinished pages on a hostname nobody monitors Frozen configuration endpoints and keys from build time still live after a rotation Quota and storage hundreds of full site copies deployment limits, slower listings Stale links a URL shared in chat outlives the branch readers see a version that never shipped
The indexing cost is the one that reaches readers. The others are hygiene — but hygiene that a security review will eventually ask about.

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.

Preview lifecycle with event teardown and a scheduled sweep A preview is created when a pull request opens and updated on each push. When the pull request closes, an event-driven teardown deletes it. A weekly sweep deletes anything older than fourteen days whose pull request is no longer open, catching teardowns that failed. Two mechanisms, because one of them will fail PR opened preview created each push preview updated PR closed teardown fires gone weekly sweep older than 14 days · no open PR catches every failed teardown The sweep never touches a preview whose pull request is still open, however old the branch is
Event-driven teardown handles the normal case in seconds; the sweep exists because the normal case is not the only case.

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
Three levels of preview protection Three tiers. Public documentation previews use noindex and an unguessable hostname. Embargoed content adds an identity-based access rule at the edge. Regulated content adds an audit log of who viewed which preview. Each tier is a superset of the one before. Match the protection to what is on the page Public documentation — noindex header + unguessable hostname no login, no friction for reviewers, nothing sensitive at stake Embargoed content — add an identity policy at the edge a pasted link is useless to anyone outside the organisation Regulated content — add an access log who viewed which preview, retained alongside the deployment record
Each tier is cheap and additive. The mistake is applying the third tier to a public docs site, which adds a login step to every review for no benefit.

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:

MeasureBeforeAfter
Live preview deployments4126 (open PRs only)
Preview URLs in the search index370 (after removal requests)
Preview storage31 GB480 MB
Deployments listed by the API412 (slow paging)6
Previews holding a rotated token1180

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.txt alone. It prevents crawling, not indexing. Serve X-Robots-Tag: noindex as 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.