Medium wave · 540–1600 kHz78 stn
1408kHz

Your Preview Deploy Is a Public Website

An exact mirrored twin of a vast brass engine hall standing behind a glass door left ajar, the duplicate machinery running unattended in ghost-pale violet neon while the original glows amber beyond it, dark steampunk foundry adrift in fog, no guard rail or barrier between the two halls

You open a pull request. Your host builds it, deploys it, and drops a URL into the PR thread. You click it, check the layout, approve the change.

What you just created was not a preview. It was a second website: every page, every asset, every piece of metadata your production site has, served over TLS on a hostname with no access control, by a CDN that will happily return 200 to anyone who asks.

The word “preview” is doing a lot of work in that sentence. Nothing about the artifact is preview-shaped. It is the same dist/ directory you would ship, on a different host, and the only thing separating it from your real site is that you assume nobody knows the URL.


“Unguessable” is not access control

The default assumption behind preview deploys is obscurity: the hostname has a random hash in it, so only the people in the PR thread have it.

That assumption survives about ten minutes of contact with reality.

  • Chat unfurls fetch the URL. Paste a preview link into Slack or Teams and the platform’s crawler requests the page server-side to build the link card. Your “private” URL has now been fetched by a third-party bot and stored in a message index.
  • Public repos publish it. If the deploy bot comments the URL on a PR in a public repository, it is in the GitHub API, in the event firehose, and in everyone’s scraper.
  • Certificate Transparency is a public log. Issue a TLS certificate for staging.yourdomain.com or pr-482.yourdomain.com and that hostname is written to an append-only log anyone can query. Bots watch those logs specifically to find new hosts, and they arrive within minutes. Platform-owned preview domains usually hide behind a wildcard certificate, but the moment you attach a preview or staging environment to your domain, the name is public the instant the cert is issued.
  • Everything with a URL bar leaks. Extensions, DNS resolvers, and enterprise proxies all see the hostname. Some of them log it.

None of this is exotic. The point is only that a URL is not a secret, and a static host has no layer where it could become one. There is no session, no role, no per-request check — there is a filesystem and a CDN.


The one file that should differ is the one that can’t

The conventional answer to “keep crawlers out of staging” is robots.txt. Look at where it lives:

public/robots.txt  →  dist/robots.txt  →  https://any-host/robots.txt

It is a static asset. It gets copied verbatim into every build, for every environment, because the build has no idea which hostname will serve it. This site’s copy is checked into the repo and hardcodes production:

User-agent: *
Allow: /

Sitemap: https://staticsignal.io/sitemap-index.xml

Deploy that to a preview host and it cheerfully tells every crawler the preview is open for business, then hands them a sitemap on a different origin.

Swapping Allow for Disallow on previews does not fix it either, and not only because the file can’t vary. Disallow blocks crawling, not indexing. A URL discovered somewhere else — a chat unfurl, a scraped PR comment — can still land in an index as a bare, title-less entry, and because you blocked the crawl, the engine never fetches the page and never sees any noindex you later add. Blocking the crawler is the move that makes a bad entry permanent.

The tool that actually works is a response header:

X-Robots-Tag: noindex, nofollow

Headers come from host configuration, not from your repo — which makes them the only part of the response that can know which environment it belongs to. That is the whole distinction. Files are built once and served everywhere; headers are attached per host.

Most platforms already set that header on the preview hostnames they generate for you. Check that yours does, then check what happens the moment you point a custom domain at a staging environment — that protection is normally scoped to the platform’s own domain, and a staging.yourdomain.com alias routinely falls outside it.


Absolute URLs pick a side at build time

Static builds bake absolute URLs into the output: canonical tags, OpenGraph images, the sitemap, the RSS feed. All of them come from one config value. In Astro it’s the site key, but the pattern is the same everywhere:

export default defineConfig({
  site: process.env.SITE_URL || 'https://staticsignal.io',
})

That single line has two failure modes and no third option.

Build the preview with the production URL — the fallback above, since SITE_URL is usually unset on preview — and every page on the preview host emits a canonical pointing at production. It feels safe, and it is the more common choice. But the preview is now a complete copy of your site claiming to be production, and you have lost the ability to verify the metadata that matters most. Test an OG card against the preview URL and the scraper reads production’s image. Open the sitemap and it lists pages that may not exist in this build. The one build you cannot inspect is the one that decides all your URLs.

Build the preview with the preview URL and the metadata becomes honest but self-advertising. The sitemap now enumerates every ephemeral preview page. The RSS feed ships items whose <guid> values are preview URLs — and a reader that fetches that feed treats them as brand-new items, then keeps the entries long after the host is torn down. Canonical tags self-reference a hostname that will 404 in a week.

Take the second option. Honest metadata plus a noindex header beats a lie in the markup plus a hostname that is reachable anyway. Then derive the value from the platform instead of hardcoding it:

// Each platform exposes its own deploy URL. Pick the first one present.
const platformUrl =
  process.env.RENDER_EXTERNAL_URL ||          // Render
  process.env.DEPLOY_PRIME_URL ||             // Netlify
  (process.env.VERCEL_URL && `https://${process.env.VERCEL_URL}`) ||
  process.env.CF_PAGES_URL                    // Cloudflare Pages

const site = process.env.SITE_URL || platformUrl || 'http://localhost:4321'

Note the missing production fallback. That is deliberate.


Assert the environment, don’t assume it

The dangerous build is not the preview that leaks. It’s the production build that silently comes out wrong: a missing SITE_URL, a renamed platform variable, a CI job that lost its env group. You find out weeks later when someone notices every canonical on the site points at pr-91.onrender.com.

So make it a build-time assertion. Production is the case that gets checked hardest, because it is the only one where being wrong is expensive:

const isProduction = process.env.DEPLOY_ENV === 'production'

if (isProduction && site !== 'https://staticsignal.io') {
  throw new Error(
    `Production build resolved site to "${site}". Refusing to build.`
  )
}

Three lines, and an invisible weeks-long SEO failure becomes a red build. Invalid content should fail the build; so should an invalid environment.

Generate robots.txt from the same value rather than shipping a static one. If the file has to exist in every environment, at least let it describe the environment it is in:

// src/pages/robots.txt.js — rendered at build time, per environment
export function GET() {
  const body = isProduction
    ? `User-agent: *\nAllow: /\n\nSitemap: ${site}/sitemap-index.xml\n`
    : `User-agent: *\nDisallow: /\n`

  return new Response(body, {
    headers: { 'Content-Type': 'text/plain' },
  })
}

The non-production branch there is a backstop, not the defense. The header is the defense.


The preview that writes to production

Indexing is the visible problem. The quieter one is that a preview build is byte-for-byte the same bundle as production, which means it carries the same keys.

Your analytics snippet reports to the production property. Your contact form posts to the production endpoint. Your newsletter signup creates real subscribers. Your CMS preview hook triggers a real rebuild. None of this is a bug in the preview — it is the correct behavior of code that was never told which environment it is running in.

The instinct is to go looking for an analytics filter that excludes staging traffic. That’s the wrong layer. Preview traffic shows up in production analytics because you shipped the production write key to a host you never meant to be real, and every env var in a static build is public the moment it lands in the bundle. Give non-production builds different keys, or no keys.


Verify it from outside the build

Your build cannot tell you whether the deployed host is indexable, because the header is applied by something the build never runs. So check the deployment, not the artifact:

# Previews and staging must be un-indexable.
curl -sI "$PREVIEW_URL" | grep -qi '^x-robots-tag:.*noindex' \
  || { echo "FAIL: preview is indexable"; exit 1; }

# Production must NOT be.
curl -sI https://staticsignal.io/ | grep -qi '^x-robots-tag:.*noindex' \
  && { echo "FAIL: production is noindexed"; exit 1; }

Run both. The second assertion is the one that will save you. A noindex header is trivially easy to apply to the wrong host — a rule written for *.yourdomain.com, a config block copied between environments — and it deindexes your entire site quietly, over days, while every page keeps returning 200 and looking perfect in a browser.

Same failure shape as a redirect loop or a stale service worker: correct-looking output, no error anywhere, and a cost you can only discover from the outside.


Every deploy target is a website

Stop treating preview environments as a rendering of your site and start treating them as deployments of it, because that is what they are.

For each one, three questions should have answers you can state out loud: what hostname does it serve, who can reach it, and what does it claim to be. If the answers for staging and production are the same, you are not running a preview. You are running two production sites and monitoring one.