Infrastructure
Infrastructure

Environments & CI/CD: from a commit to production

Full · ~50 min
After this unit you can
  • 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-ignore filters and PR activity, and what concurrency does 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

Simple
1 / 2

Environments — same code, different config, secrets and data

Flag

An 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:

EnvironmentWho uses itLives forIts data
Localyouyour sessiona local or seeded database
Previewreviewers of one PRas long as the branchits own throwaway or seeded database
Stagingthe team, before releaselong-livedproduction-shaped, not production
Productionyour usersfor goodthe 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.

Medium
1 / 2

When a workflow runs — branch and path filters, PR activity, concurrency

Flag
on:
  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: true additionally 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.

Complex
1 / 3

CI minutes are a budget — how private-repo runs are billed

Flag

Standard 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:

PlanIncluded minutes per month
GitHub Free (personal or organization)2,000
GitHub Pro / GitHub Team3,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:

ShapeBilled
one job, 160 s3 minutes
four parallel jobs, 40 s each4 minutes
an 8-job matrix, 20 s each8 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-branch concurrency group and cancel-in-progress: true), and the same workflow firing on both push and pull_request for one commit (scope push to main).
  • Per-run downloads. npx playwright install --with-deps installs browsers and their Linux packages — on Ubuntu runners Playwright runs apt-get update and apt-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?

Flag

You rotate an API key by editing the variable in Vercel's Production scope. When does the live site start using the new key?

Flag

A workflow has three jobs — lint, test, deploy — and none uses needs. What happens on a push?

Flag

In a private repository, a job declares only permissions: { id-token: write }. Its first step is actions/checkout. What happens?

Flag

Why deploy to a cloud account with OIDC (id-token: write) rather than an AWS_SECRET_ACCESS_KEY repository secret?

Flag

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?

Flag

A private repo is running out of Actions minutes. Which change cuts minutes without losing test coverage on any commit that reaches main?

Flag

A change adds a nullable column and code that writes to it. How should it ship?

Flag

Release 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?

Flag

Fill in the blank

Make deploy wait for test, and be skipped if it fails.

Flag
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.

Flag
concurrency:
  group: ci-${{ github. }}
  cancel-in-progress: 

Skip this workflow for PRs that change nothing outside docs/.

Flag
on:
  pull_request:
    :
      - "docs/**"

Also run when a PR is retargeted onto a new base branch.

Flag
on:
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened, ]

Let this job check out the code and request an OIDC token, and nothing else.

Flag
permissions:
  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.
Flag
on:
  pull_request:
    paths-ignore:
      - "docs/**"
      - "**/*.md"

# The PR changes: docs/setup.md, README.md, src/login.ts
Flag
concurrency:
  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.
Flag
jobs:
  lint:      # 40 s
  typecheck: # 40 s
  unit:      # 40 s
  build:     # 40 s
# Four parallel jobs on ubuntu-latest, in a private repository.
Flag

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:

  1. branches on pull_request means the branch the PR targets — its base. A PR from feature/login-ui into feature/login (a stacked PR) does not match branches: [main].
  2. pull_request without types listens for opened, synchronize and reopened only. Retargeting a PR onto main is an edited event, so it doesn't start the workflow by itself.
  3. paths-ignore skips a run only when every changed file matches. One file outside the ignored patterns is enough to run. (paths is the opposite: one matching file is enough.)
  4. 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: max changes it.)
  5. 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 branches filter is matched against the branch it targets, so a stacked PR doesn't run a branches: [main] workflow
  • Without types, only opened, synchronize and reopened start a pull_request workflow; edited (a retarget) does only when listed
  • paths-ignore skips a run only when every changed file is ignored; paths runs 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
  • billedMinutes rounds each job up to a whole minute: [40, 40, 40, 40] bills 4

Loading the in-browser editor…

Further reading