Environments & CI/CD: from a commit to production
- Explain what differs between local, preview, staging and production — config, secrets and data — and keep preview deployments off the production database
- Read a GitHub Actions workflow —
on, jobs, steps,needs,permissions,environment— and say what runs, in what order, with which access - Predict whether a workflow runs from its
branches,paths/paths-ignorefilters and PR activity, and whatconcurrencydoes to a newer run - Estimate a private repository's billed minutes (each job rounds up to a whole minute) and cut CI cost without losing coverage
- Order a release that needs a schema change, and say what a rollback does and doesn't undo
Read
Environments — same code, different config, secrets and data
FlagAn environment is a running copy of your app plus everything it's wired to: its configuration, its secrets and its data. Most teams have four:
| Environment | Who uses it | Lives for | Its data |
|---|---|---|---|
| Local | you | your session | a local or seeded database |
| Preview | reviewers of one PR | as long as the branch | its own throwaway or seeded database |
| Staging | the team, before release | long-lived | production-shaped, not production |
| Production | your users | for good | the real thing |
The point of having more than one is to test the same code before users meet it. So the rule is: the code and the build are the same everywhere; only configuration differs. If staging builds with different flags or runs a different command, a green staging tells you little about production.
What legitimately differs, and lives in environment variables rather than in code:
- Config — API base URLs, feature flags, log levels.
- Secrets — each environment gets its own keys: a test-mode payment key in preview, the live one only in production. A leaked preview key should be worthless.
- Data — which database the app connects to.
Hosting platforms scope variables per environment. On Vercel, for example, every variable is
assigned to Production, Preview and/or Development (Pro and Enterprise plans can add custom
environments such as staging); Preview applies to every branch that isn't the production
branch, and a branch-specific value overrides it. One detail catches people:
Vercel applies a changed variable only to new deployments, so you redeploy to pick it up.
The trap: a preview on the production database
The quickest way to get a working preview is to copy production's DATABASE_URL into the
Preview scope. Now every pull request's deployment — code nobody has approved yet — reads and
writes real customer data. A branch that adds a column, or a migration that runs on deploy,
changes production's schema before the PR is merged, or even if it never is.
Give previews their own database: a seeded one shared by all previews, or a database branch per PR if your provider offers it. "Preview" should mean safe to break.
When a workflow runs — branch and path filters, PR activity, concurrency
Flagon:
pull_request:
branches: [main]
paths-ignore:
- "docs/**"
- "**/*.md"
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Every rule below is from GitHub's Actions documentation, and each one surprises someone.
branches means different things per event. Under push it's the branch pushed to. Under
pull_request it's the branch the PR targets — its base. A PR from feature/login into
main matches branches: [main]; a PR from feature/login-ui into feature/login (a stacked
PR) does not, so this CI never runs on it.
pull_request listens to three activities by default: opened, synchronize (new commits
on the PR's branch) and reopened. Retitling a PR — or changing its base branch — is edited,
which isn't in the list. Retarget that stacked PR onto main after its parent merges, and CI
still doesn't start until something is pushed to it (or it's closed and reopened), unless the
workflow lists edited under types.
paths runs if any file matches; paths-ignore skips only if every file matches. A PR
that changes docs/setup.md and src/login.ts runs, because one file isn't ignored. Patterns
match from the repository root, and * stops at / while ** crosses it: docs/* matches
docs/a.md but not docs/guides/a.md; docs/** matches both. When a workflow has branch
and path filters, both must be satisfied.
A skipped workflow doesn't report success. If a filter skips a workflow whose check is required by branch protection, the check stays "Pending" and the PR can't merge. GitHub's advice is blunt: avoid requiring workflows that can be skipped.
Concurrency: one running, one waiting
A concurrency group is a name; runs that compute the same name share one slot. GitHub's
rules for a group (with the default queue: single):
- at most one run executes;
- at most one run waits — when a newer run arrives, the waiting one is cancelled and the newcomer takes its place;
cancel-in-progress: trueadditionally cancels the run that's executing.
So three quick pushes to a branch with cancel-in-progress: false give: run 1 executes,
run 2 is cancelled when run 3 arrives, run 3 runs after run 1. With true, as above, only the
newest push is ever worth finishing — right for PR checks, where an older commit's result is
useless the moment a newer one exists.
The group name decides what competes. ci-${{ github.ref }} gives each branch or PR its own
group, so two PRs never cancel each other. A bare group: ci would make every PR in the
repository fight over one slot.
CI minutes are a budget — how private-repo runs are billed
FlagStandard GitHub-hosted runners are free for public repositories. For private ones, every account gets a monthly allowance and pays (or is blocked, with no payment method on file) beyond it. GitHub's billing docs, October 2026:
| Plan | Included minutes per month |
|---|---|
| GitHub Free (personal or organization) | 2,000 |
| GitHub Pro / GitHub Team | 3,000 |
The allowance is per account, shared by every private repository the account owns, and charged to the repository owner no matter who triggered the run.
The rounding rule
GitHub rounds the minutes and partial minutes each job uses up to the nearest whole minute.
Each job, not each run. So the same 160 seconds of work costs differently by shape:
| Shape | Billed |
|---|---|
| one job, 160 s | 3 minutes |
| four parallel jobs, 40 s each | 4 minutes |
| an 8-job matrix, 20 s each | 8 minutes |
And each job pays its own setup: a fresh runner, checkout, toolchain, dependency install. A
job that does 5 seconds of linting after 50 seconds of install bills a full minute for 55
seconds of mostly setup. Splitting a pipeline into many small parallel jobs makes it faster,
and multiplies the bill.
Where minutes leak
- Runs nobody needed. Docs-only PRs (fix with
paths-ignore— but not on a required check), superseded pushes to a PR (fix with a per-branchconcurrencygroup andcancel-in-progress: true), and the same workflow firing on bothpushandpull_requestfor one commit (scopepushtomain). - Per-run downloads.
npx playwright install --with-depsinstalls browsers and their Linux packages — on Ubuntu runners Playwright runsapt-get updateandapt-get install— on every run, and Playwright's CI guide notes those OS dependencies can't be cached. Installing one browser instead of all of them (npx playwright install --with-deps chromium) shrinks it; running the job in Playwright's prebuilt Docker image, which already contains the browsers and their packages, removes it. - Expensive runners. Windows and macOS minutes cost more than Linux in GitHub's price list ($0.010 and $0.062 per minute against $0.006 for a standard 2-core Linux runner), and larger runners can't use included minutes at all.
- Retries. A failed run's minutes are spent; the re-run spends them again.
Quiz
Someone set the Preview scope's DATABASE_URL to production's. A PR adds a column, and the app runs migrations when it starts. When does production's schema change?
You rotate an API key by editing the variable in Vercel's Production scope. When does the live site start using the new key?
FlagA workflow has three jobs — lint, test, deploy — and none uses needs. What happens on a push?
In a private repository, a job declares only permissions: { id-token: write }. Its first step is actions/checkout. What happens?
Why deploy to a cloud account with OIDC (id-token: write) rather than an AWS_SECRET_ACCESS_KEY repository secret?
ci.yml has on.pull_request.paths: ['src/**'], and its test check is required by branch protection. A PR changes only README.md. What does the PR show?
A private repo is running out of Actions minutes. Which change cuts minutes without losing test coverage on any commit that reaches main?
A change adds a nullable column and code that writes to it. How should it ship?
FlagRelease 42 dropped a column that release 41 still reads. Release 42 has a bug, so you use Vercel's Instant Rollback to 41. What happens?
FlagFill in the blank
Make deploy wait for test, and be skipped if it fails.
jobs: test: runs-on: ubuntu-latest steps: - run: npm test deploy: : test runs-on: ubuntu-latest steps: - run: npm run deploy
One concurrency slot per branch or PR, and a new push cancels the run that's executing.
Flagconcurrency: group: ci-${{ github. }} cancel-in-progress:
Skip this workflow for PRs that change nothing outside docs/.
on: pull_request: : - "docs/**"
Also run when a PR is retargeted onto a new base branch.
Flagon: pull_request: branches: [main] types: [opened, synchronize, reopened, ]
Let this job check out the code and request an OIDC token, and nothing else.
Flagpermissions: contents: id-token:
Predict the output
on:
pull_request:
branches: [main]
# PR #11: feature/login -> main
# PR #12: feature/login-ui -> feature/login (stacked on #11)
# A new commit is pushed to PR #12.on:
pull_request:
paths-ignore:
- "docs/**"
- "**/*.md"
# The PR changes: docs/setup.md, README.md, src/login.tsconcurrency:
group: ci-${{ github.ref }}
cancel-in-progress: false
# Three pushes to the same branch, seconds apart: A, then B, then C.
# Each run takes 5 minutes.jobs: lint: # 40 s typecheck: # 40 s unit: # 40 s build: # 40 s # Four parallel jobs on ubuntu-latest, in a private repository.
Challenge
Dry-run GitHub Actions: triggers, concurrency and billed minutes
Flag"Why didn't CI run on my PR?" and "why did we burn 2,000 minutes this month?" are both
questions about rules GitHub applies before a single step executes. ci-planner.ts is a dry
run of those rules for the workflow below; it gets the common cases right and the ones that
cost you an afternoon wrong.
on:
pull_request:
branches: [main]
paths-ignore: ["docs/**", "**/*.md"]
push:
branches: [main]
workflow.ts holds the types and glob.ts matches GitHub's * / ** patterns, both
read-only. The rules, from GitHub's workflow syntax and billing docs:
branchesonpull_requestmeans the branch the PR targets — its base. A PR fromfeature/login-uiintofeature/login(a stacked PR) does not matchbranches: [main].pull_requestwithouttypeslistens foropened,synchronizeandreopenedonly. Retargeting a PR ontomainis aneditedevent, so it doesn't start the workflow by itself.paths-ignoreskips a run only when every changed file matches. One file outside the ignored patterns is enough to run. (pathsis the opposite: one matching file is enough.)- A concurrency group runs one run and keeps at most one waiting. When a newer run
arrives, the waiting one is cancelled and the newcomer waits in its place, so the latest
commit is what runs next. (This is the default;
queue: maxchanges it.) - Billing rounds each job up to a whole minute. Four 40-second jobs bill 4 minutes; the same 160 seconds in one job bill 3.
Your task
Edit ci-planner.ts so the tests pass:
- A pull request's
branchesfilter is matched against the branch it targets, so a stacked PR doesn't run abranches: [main]workflow - Without
types, onlyopened,synchronizeandreopenedstart apull_requestworkflow;edited(a retarget) does only when listed paths-ignoreskips a run only when every changed file is ignored;pathsruns when at least one file matches- In a busy concurrency group a newer run replaces the waiting one, which is cancelled; the running one is untouched
billedMinutesrounds each job up to a whole minute:[40, 40, 40, 40]bills 4
Further reading
- GitHub docs: Workflow syntax for GitHub Actions
- GitHub docs: Events that trigger workflows
- GitHub docs: Deployments and environments
- GitHub docs: OpenID Connect
- GitHub docs: OpenID Connect reference
- GitHub docs: GitHub Actions billing
- GitHub docs: Actions runner pricing
- GitHub docs: Troubleshooting required status checks
- Vercel docs: Environments
- Vercel docs: Environment variables
- Vercel docs: Instant Rollback
- Playwright docs: Continuous Integration