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
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.
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.
Measured Impact
A documentation site with three environments, before and after consolidating three workflows into one:
| Measure | Three workflows | One workflow, promoted artifact |
|---|---|---|
| Build minutes per merge | 6.9 min (3 builds) | 2.3 min (1 build) |
| Config drift incidents per quarter | 4 | 0 |
| Staging/production artifact identical | No | Yes |
| Repository-wide secrets | 3 | 0 (all environment-scoped) |
| Time from merge to production | 11 min | 5 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
noindexon 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.
Related
- Parent: GitHub Actions for Automated SSG Builds — the build this promotes.
- Securing Deploy Credentials in GitHub Actions — token scoping in practice.
- Preview Environments for Pull Requests — the first environment in the chain.
- Rollbacks and Deploy Safety for Static Sites — what promotion makes reversible.
- Running Smoke Tests Against a Preview URL — the check each environment runs.