CI/CD Basics: Automate Deploys with GitHub Actions
Stop deploying by hand. Learn how to set up continuous integration and deployment with GitHub Actions — tests on every push, deploys on every merge.
CI/CD is the automation that stands between a git push and production. Continuous integration means every push is automatically built and tested, so broken changes surface in minutes instead of at release time; continuous delivery (or deployment) extends that so releases become an automated consequence of merging rather than a manual ritual. GitHub Actions is GitHub’s built-in CI/CD system: you describe the pipeline in a YAML file that lives in your repo, and GitHub runs it on every event you care about.
This tutorial builds a working CI pipeline for a Node project, then layers on what you’ll want next — matrix builds, secrets, a gated deploy job, and cancellation of superseded runs. If Git itself is still new, read what Git is first.
The five words that matter
Actions has a small vocabulary, and everything hangs off it:
- Workflow — a YAML file in
.github/workflows/. A repo can have several. - Trigger — the event that starts a workflow: a
push, apull_request, a schedule (standard five-field cron syntax — sanity-check yours with our cron parser), a manual click. - Job — a group of steps that runs on one fresh virtual machine. Jobs run in parallel unless you declare dependencies.
- Step — a single unit inside a job: either a shell command (
run:) or a reusable action (uses:). - Runner — the machine that executes a job. GitHub hosts Linux, macOS, and Windows runners; you can also self-host.
A complete CI workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
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 test
- run: npm run build
Walking through it:
on:— the workflow runs for every push tomainand every pull request targetingmain. PRs get a green check or a red X before anyone merges, andmainis re-verified after the change lands — however your team lands it (see rebase vs. merge).permissions:— the workflow’s token can read the repo and nothing else. More on why below.runs-on: ubuntu-latest— a fresh Linux VM per run. Nothing persists between runs, which is the point: your build can’t quietly depend on leftover state.actions/checkout— clones your repository onto the runner. Without it, the machine is empty.actions/setup-nodewithcache: npm— installs Node 20 and caches the npm download cache keyed on your lockfile, cutting install time on repeat runs.npm ci— installs exactly whatpackage-lock.jsonspecifies. Prefer it tonpm installin CI: it’s faster and it fails loudly if the lockfile is out of sync.npm testandnpm run build— any nonzero exit code fails the step, the job, and the check on the PR.
Commit the file, push, and watch the run appear in the repo’s Actions tab. That’s the entire setup.
Testing across Node versions with a matrix
If you ship a library, one Node version isn’t enough. A matrix fans a job out into one copy per combination:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
Three jobs run in parallel, and the PR check fails if any one of them fails. The same mechanism scales to operating systems — add an os axis and set runs-on: ${{ matrix.os }} to cover macOS and Windows in the same run.
Secrets and a deploy job
Deployments need credentials, and credentials must never appear in the YAML or anywhere else in the repo. Store them under the repository’s settings as Actions secrets, then reference them as ${{ secrets.NAME }} — GitHub injects the value at runtime and masks it in logs.
A deploy job depends on the test job and is gated to main:
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci && npm run build
- run: npx wrangler pages deploy dist
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
needs: test means it waits for tests to pass; the if: guard means pull request runs never deploy. This example ships a static build to Cloudflare Pages — the same pattern as deploying Astro to Cloudflare Pages — but the shape is identical for any target, including building and pushing a Docker image to a registry.
Cancel superseded runs
Push three commits to a PR in quick succession and you get three workflow runs racing, two of them testing stale code. concurrency fixes that:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Each branch gets one live run; a new push cancels the previous one. On a busy repo this saves real minutes and real money. One caveat: leave cancel-in-progress off for deploy workflows, where killing a run halfway through is worse than letting it finish.
Pitfalls worth knowing about
- Set minimal
permissions:. The workflow token can do more than most workflows need by default. Declaringcontents: readat the top means a compromised dependency or test script can’t push commits, open PRs, or tamper with releases from inside your pipeline. - Be careful with
pull_request_target. Unlikepull_request, it runs with access to your secrets even for PRs opened from forks. Combine it with a checkout of the fork’s code and you’ve handed your secrets to any stranger who opens a PR. If you don’t specifically need it, don’t use it. - Pin third-party actions.
actions/checkout@v4trusts GitHub;some-user/action@maintrusts a stranger’s account security. Pin third-party actions to a full commit SHA so the code you reviewed is the code that runs — the same logic behind lockfiles, and a core theme of software supply-chain security.
The takeaway
CI/CD with GitHub Actions is one YAML file away: trigger on pushes and PRs to main, check out the code, set up Node with caching, then npm ci, test, and build. Add a matrix when you support multiple runtimes, keep credentials in secrets, gate deploys behind needs: and a branch check, and let concurrency kill stale runs. Set minimal permissions and pin what you don’t control, and the pipeline stays boring — which is the best thing a pipeline can be.
Tagged
Keep reading
Chisato · · 4 min read Anatomy of an Outage: How a Bad Update Bricked 8.5M PCs
One faulty CrowdStrike update blue-screened 8.5 million Windows machines and grounded flights. A teardown of how a config file became a global outage.
Chisato · · 3 min read What Is a Runbook? Incident Response Playbooks
A runbook is a step-by-step document for handling a specific operational task or incident, turning tribal knowledge into a repeatable procedure.
Chisato · · 4 min read Kubernetes ConfigMaps vs Secrets: What's the Difference
ConfigMaps store non-sensitive configuration; Secrets store credentials with base64 encoding and tighter access controls. When to use each.