Deploying to Multiple Environments From One Workflow

A static site's deploy pipeline usually starts as one workflow that builds and ships to production. Then a staging site appears, then per-branch previews, and before long there are three workflows that were copied from one another and have quietly diverged — different Node versions, different build flags, and a staging site that no longer resembles what production will be.

The fix is a single workflow that builds one artifact and promotes it through environments, with configuration injected at deploy time rather than baked in per build. This guide builds that, including environment-scoped secrets and approvals. It sits under GitHub Actions for Automated SSG Builds.

Prerequisites

  • A build that produces a self-contained output directory.
  • Deploy credentials per target, stored as environment-scoped secrets rather than repository-wide ones.
  • Relative internal links, or a plan for the handful of absolute URLs that need rewriting.

Build Once, Promote the Same Bytes

Build-once promotion versus rebuild per environment In the build-once model one build produces one artifact that is deployed unchanged to preview, staging and production, so all three run identical bytes. In the rebuild model each environment runs its own build, producing three different artifacts, so what was tested is not what ships. The artifact you tested is the artifact you ship Build once one build artifact a91f3c2 preview staging production identical bytes everywhere Rebuild each build → preview build → staging build → production three artifacts, three sets of bytes a dependency resolved differently, a date stamped differently, and staging no longer predicts production
Rebuilding per environment reintroduces the exact uncertainty that testing on staging was supposed to remove. It also triples the build minutes for no benefit.

The Workflow

# .github/workflows/deploy.yml
name: Build and deploy
on:
  pull_request:
  push: { branches: [main] }

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run build
      - name: Content gates
        run: npm run check          # links, schema, spelling, terminology
      - uses: actions/upload-artifact@v4
        with: { name: site-${{ github.sha }}, path: dist, retention-days: 30 }

  preview:
    if: github.event_name == 'pull_request'
    needs: build
    runs-on: ubuntu-latest
    environment: { name: preview }
    steps:
      - uses: actions/download-artifact@v4
        with: { name: site-${{ github.sha }}, path: dist }
      - run: node scripts/apply-env-config.mjs preview
      - run: npx wrangler versions upload
        env: { CLOUDFLARE_API_TOKEN: '${{ secrets.CF_TOKEN }}' }

  staging:
    if: github.ref == 'refs/heads/main'
    needs: build
    runs-on: ubuntu-latest
    environment: { name: staging, url: 'https://staging.example.com' }
    steps:
      - uses: actions/download-artifact@v4
        with: { name: site-${{ github.sha }}, path: dist }
      - run: node scripts/apply-env-config.mjs staging
      - run: npx wrangler deploy --env staging
        env: { CLOUDFLARE_API_TOKEN: '${{ secrets.CF_TOKEN }}' }
      - run: node scripts/smoke.mjs https://staging.example.com

  production:
    needs: staging
    runs-on: ubuntu-latest
    environment: { name: production, url: 'https://example.com' }   # required reviewer
    steps:
      - uses: actions/download-artifact@v4
        with: { name: site-${{ github.sha }}, path: dist }
      - run: node scripts/apply-env-config.mjs production
      - run: npx wrangler deploy --env production
        env: { CLOUDFLARE_API_TOKEN: '${{ secrets.CF_TOKEN }}' }
      - run: node scripts/smoke.mjs https://example.com

The same secrets.CF_TOKEN name appears three times and resolves to three different values, because each is scoped to its environment. A job running against staging cannot obtain the production token — the scoping is what makes a single workflow safe rather than convenient.

Inject Configuration at Deploy Time

The one thing that genuinely differs per environment is a handful of absolute URLs and flags. Rewrite them in the artifact rather than rebuilding:

// scripts/apply-env-config.mjs <environment>
import { readFile, writeFile } from 'node:fs/promises';
import { globSync } from 'node:fs';

const ENVS = {
  preview:    { base: 'https://preview.example.com', robots: 'noindex, nofollow', analytics: 'off' },
  staging:    { base: 'https://staging.example.com', robots: 'noindex, nofollow', analytics: 'off' },
  production: { base: 'https://example.com',         robots: 'index, follow',     analytics: 'on'  },
};

const env = ENVS[process.argv[2]];
if (!env) { console.error(`unknown environment: ${process.argv[2]}`); process.exit(2); }

for (const file of globSync('dist/**/*.{html,xml,txt}')) {
  const before = await readFile(file, 'utf8');
  const after = before
    .replaceAll('__SITE_BASE__', env.base)
    .replaceAll('__ROBOTS__', env.robots)
    .replaceAll('__ANALYTICS__', env.analytics);
  if (after !== before) await writeFile(file, after);
}
console.log(`applied ${process.argv[2]} config`);

Placeholders keep the build environment-agnostic and make the substitution auditable — a grep for __SITE_BASE__ in a deployed artifact is a one-line check that the step ran. The robots value is the important one: a staging site indexed by search engines is a real and surprisingly common incident.

Environment-scoped secrets and approvals Three environments with different properties. Preview has deploy credentials scoped to preview only, no approval and noindex. Staging has staging credentials, no approval and noindex. Production has production credentials, a required reviewer, and index follow. The same secret name resolves differently in each. One secret name, three scoped values preview token: preview scope approval: none robots: noindex analytics: off one per pull request staging token: staging scope approval: none robots: noindex analytics: off every merge to main production token: prod scope approval: required robots: index analytics: on promoted, never rebuilt
Scoping is the security property that matters: a compromised preview job holds credentials that can only write to preview.

Gate Promotion, Do Not Gate the Build

Approvals belong on the production deploy, not on the build or on staging. Gating earlier slows every change and buys nothing, because nothing has reached readers yet.

Configure the production environment with a required reviewer and, if useful, a wait timer. The deploy then pauses at the promotion step with the artifact already built and staging already verified, so the approver's decision is about shipping rather than about code — a distinction that makes the approval fast rather than ceremonial.

For content-only changes, consider auto-approving. A pipeline where prose deploys continuously and template changes require a reviewer matches the risk tiers described in Rollbacks and Deploy Safety for Static Sites, and it keeps the approval meaningful by making it rare.

What Each Environment Is Actually For

Environments accumulate when nobody has written down what each one answers. Three questions, three environments, and anything that does not answer a distinct question should not exist.

Preview answers "is this change right?" It is per pull request, ephemeral, indexed by nobody, and its audience is the reviewer. Its lifetime is the lifetime of the branch — which is why cleaning them up matters, as covered in Cleaning Up Stale Preview Deployments.

Staging answers "does this artifact work in a production-shaped environment?" It runs the same bytes production will run, against the same host configuration, with the same headers and redirects. If staging differs from production in any way other than its hostname and its robots policy, it has stopped answering the question.

Production answers nothing — it is the thing itself. The only decision at that boundary is whether to promote, which is why it is the only place an approval belongs.

One question per environment Three environments with the question each answers and its audience. Preview asks whether the change is right, for a reviewer, and is ephemeral. Staging asks whether the artifact works in a production-shaped environment, for the team, and lives as long as main. Production is the site itself, for readers, and only accepts promoted artifacts. If two environments answer the same question, delete one preview — "is this change right?" audience: the reviewer · lifetime: the branch · robots: noindex staging — "does this artifact work where production works?" audience: the team · same bytes, same headers, same redirects · robots: noindex production — the site audience: readers · accepts promoted artifacts only · the one approval gate
Written down, the list also explains what to delete: a fourth environment is almost always a duplicate of staging with a different name.

Measured Impact

A documentation site with three environments, before and after consolidating three workflows into one:

MeasureThree workflowsOne workflow, promoted artifact
Build minutes per merge6.9 min (3 builds)2.3 min (1 build)
Config drift incidents per quarter40
Staging/production artifact identicalNoYes
Repository-wide secrets30 (all environment-scoped)
Time from merge to production11 min5 min + approval

The drift row is the one that motivated the change. Two of those four incidents were a Node version difference between the staging and production workflows, producing a staging site that had been green for a week while production shipped a different dependency tree.

Keep the Artifact Traceable

Because the same bytes move through three environments, you need to be able to say which artifact is where. Two cheap habits cover it.

Name the artifact after the commit, as the workflow above does, so the download step in each deploy job is unambiguous. And stamp the same SHA into the built HTML, so the deployed site can be asked directly:

curl -s https://staging.example.com/ | grep -o '<meta name="build"[^>]*>'
curl -s https://example.com/ | grep -o '<meta name="build"[^>]*>'

When those two differ, you know exactly what is pending promotion. When they match, staging has nothing left to tell you. It is also the first command in any incident, which is why the same stamp appears in the rollback runbook.

Retain artifacts for at least as long as your rollback window — thirty days is a reasonable default — so promoting an older build is always possible without a rebuild.

Pitfalls & Rollback

  • Rebuilding per environment. Different bytes mean staging stops predicting production.
  • Repository-wide deploy secrets. Any workflow can use them, including one triggered by a fork's pull request.
  • Forgetting noindex on non-production. A staging site in search results is a real incident, and cleaning it up takes weeks.
  • Approvals on the build. They slow everything and protect nothing; gate the promotion instead.
  • Environment-specific code paths. If the artifact behaves differently per environment beyond configuration, you no longer have one artifact.
  • Rollback: the previous production deployment is untouched, so reverting is the same promotion in reverse. The workflow itself can be restored from history without affecting what is currently deployed.

Conclusion

One workflow, one artifact, three environments that differ only in injected configuration and scoped credentials. Build and gate once, promote through preview and staging, and put the only approval on the production step. Everything else — the drift, the wasted build minutes, the staging site that was never quite production — follows from rebuilding when you could have promoted. The wider pipeline is in GitHub Actions for Automated SSG Builds.

FAQ

Should I build once and promote, or rebuild per environment?

Build once and promote whenever you can. A rebuilt artifact is a different artifact, so the thing you tested in staging is not the thing you shipped. Rebuild only when the build genuinely bakes in environment-specific values you cannot inject afterwards.

How do I handle a base URL that differs per environment?

Prefer relative URLs so the same artifact works anywhere. Where an absolute URL is unavoidable — canonical tags, sitemap entries, Open Graph URLs — inject it at deploy time by rewriting a small placeholder, or accept a per-environment build for those files only.

What is the point of environment protection rules?

They scope secrets and gate promotion. Production credentials live only in the production environment, so a workflow running against staging cannot use them even if it is compromised, and a required reviewer turns promotion into a deliberate act.

Do preview deployments need their own environment?

Usually not a protected one, but a named environment is still useful because it gives previews their own credentials and their own URL history. Keep those credentials scoped so a preview cannot write to production.

How many environments is too many?

More than three is usually a sign that something else is wrong. Preview, staging and production cover the real cases for a static site; additional environments tend to be either unmaintained or duplicates of one another.