GitHub Actions turns your repository into a CI/CD platform with a single YAML file. This guide gives you the full mental model and a complete build → test → containerize → deploy pipeline you can adapt today.

Prerequisites

  • A GitHub repository you can push to.
  • An app with a test command (this guide uses a Node app; the pattern is identical for any language).
  • For the deploy section: a cloud account (AWS or GCP) where you can create an IAM role.

The building blocks

  • Workflow — a YAML file in .github/workflows/ triggered by an event (push, pull_request, schedule, workflow_dispatch).
  • Job — a set of steps that run on a fresh runner (a clean VM). Jobs run in parallel by default; use needs: to sequence them.
  • Step — a shell command (run:) or a reusable action (uses: actions/checkout@v4).
event → workflow → job(s) on runners → steps (run / uses)

A first CI workflow

name: CI
on:
  push:
    branches: [main]
  pull_request:

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

Commit this to .github/workflows/ci.yml, push, and every commit now runs your tests. The cache: npm line reuses downloaded dependencies between runs, often shaving minutes off the build.

Matrix builds — test everything at once

    strategy:
      matrix:
        node: [18, 20, 22]
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}

One job definition, three parallel runs across Node versions. Matrices are the fastest way to widen coverage without duplicating YAML.

Secrets and environments

Never hardcode credentials. Store them in Settings → Secrets and variables → Actions and read them as ${{ secrets.NPM_TOKEN }}. Use Environments (e.g. production) to add required reviewers, so a deploy pauses for a human click.

Keyless cloud auth with OIDC (do this)

Long-lived cloud keys stored in secrets are the #1 leak risk. Instead, let the runner exchange a short-lived OIDC token for cloud credentials — nothing to store, nothing to rotate. On the cloud side you create a role that trusts GitHub's OIDC issuer for your specific repo. Then:

    permissions:
      id-token: write     # allow the runner to request an OIDC token
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deployer
          aws-region: eu-west-1

The same pattern works for Google Cloud via Workload Identity Federation. This is the modern default — treat stored cloud keys as a smell.

The full pipeline: test → build image → deploy

name: Deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write
  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

  build-and-push:
    needs: test                     # only if tests pass
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production          # gated by required reviewers
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}:${{ github.sha }}

This is a real pipeline: tests gate the image build, the image is tagged by commit SHA (immutable and traceable), build layers are cached via type=gha, and production deploy waits for approval.

Verify it worked

  • Open the Actions tab → your run shows three jobs with green checks, deploy waiting on approval.
  • The Packages tab shows an image tagged with the commit SHA.
  • Break a test on purpose, push, and confirm the pipeline stops before building — the gate works.

Speeding up: caching strategies that actually help

CI minutes are money and momentum — a 20-minute pipeline kills the fast-feedback loop CI exists for. The biggest wins come from caching the right things:

  • Dependency cache. setup-node/setup-python/setup-go and friends have a built-in cache: option keyed on your lockfile — turn it on. It restores node_modules/.venv/module cache between runs so you don't re-download the internet every time.
  • Docker layer cache. In build-push-action, cache-from: type=gha / cache-to: type=gha reuses unchanged image layers across runs — often the single biggest speedup for containerized apps.
  • Build/tool caches. Use actions/cache for anything expensive and deterministic: compiled artifacts, a Gradle/Maven cache, a Next.js/Turborepo cache. Key it on a hash of the inputs so it invalidates correctly.
  • Split and parallelize. Independent jobs run concurrently for free; put lint, unit tests, and a build in separate jobs, and reserve the slow end-to-end suite for a job that only runs on main.
  • Right-size the runner. For heavy builds, larger GitHub-hosted runners (or self-hosted, below) cut wall-clock time. Measure first — the Actions run summary shows per-job timing.

A good rule: cheapest checks first, cache everything deterministic, and never let one slow step block fast feedback on the rest.

Self-hosted runners — when the cloud runner isn't enough

GitHub's hosted runners are ideal for most work, but you'll want self-hosted runners when you need: access to private infrastructure (a database or cluster on a private network), specialized hardware (GPUs, lots of RAM, ARM), large caches kept warm on local disk, or simply cheaper minutes at high volume. You register a runner (a small agent) on your own VM or in your Kubernetes cluster and target it with runs-on: [self-hosted, linux, gpu].

The trade-off is you now own security and maintenance. A self-hosted runner executes whatever the workflow says, so never attach one to a public repo that accepts fork PRs (a malicious PR could run code on your infra). Use ephemeral runners (fresh environment per job), keep them patched, and scope their credentials tightly. For Kubernetes, the Actions Runner Controller (ARC) scales runners as pods on demand.

Security hardening the pipeline

CI/CD is a high-value target — it has credentials and ships to production. Harden it deliberately:

  • Prefer OIDC over stored cloud keys (covered above) — nothing long-lived to leak.
  • Pin third-party actions to a full commit SHA, not a moving tag: uses: foo/bar@a1b2c3d.... A tag can be re-pointed at malicious code; a SHA can't.
  • Least-privilege the GITHUB_TOKEN. Set permissions: to the minimum (default read-only, grant packages: write etc. only where needed).
  • Guard secrets on forks. Forked-PR workflows don't get your secrets by design — don't defeat that with pull_request_target unless you know exactly what you're doing.
  • Add scanning as gates. A dependency scan (Dependabot/Trivy) and a secret scanner (gitleaks) as CI steps catch vulnerable packages and committed credentials before merge.
  • Protect main with required reviews and required status checks, and require environments for production deploys.

A monorepo pattern: build only what changed

In a repo with several apps, running everything on every push is wasteful. Use path filters and dynamic matrices to build only affected projects:

on:
  push:
    paths: ["services/api/**"]      # this workflow only runs when the API changes
jobs:
  build:
    strategy:
      matrix:
        service: [api]              # or generate the list from changed dirs

For a fully dynamic version, a first job detects changed directories and emits a matrix via outputs, which later jobs consume — so a one-line docs change doesn't trigger a full 40-minute build of ten services. Tools like Turborepo/Nx add caching on top of this.

Composite & custom actions

When you find yourself pasting the same handful of steps into many workflows — checkout, set up a language, restore a cache, configure auth — package them into a composite action. It's a small action.yml that bundles steps behind a single uses:, with typed inputs and outputs:

# .github/actions/setup/action.yml
name: setup
inputs:
  node-version: {default: "20"}
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with: {node-version: ${{ inputs.node-version }}, cache: npm}
    - run: npm ci
      shell: bash

Now every workflow calls uses: ./.github/actions/setup. Composite actions live in your repo; for sharing across an org, publish an action to its own repo (or the Marketplace). This is how teams keep dozens of pipelines consistent — fix a step once, everyone gets it.

Deployment strategies from Actions

Actions can do more than "deploy on green" — it orchestrates real release strategies:

  • Environments with approvals gate production behind a human click and scope production secrets.
  • Canary / gradual rollout — deploy to a small slice, run a smoke test job, then a follow-up job promotes to 100% (or rolls back on failure).
  • Blue-green — deploy to an idle environment, verify, then flip traffic; a later job can tear down the old one.
  • Automatic rollback — because you tag images by commit SHA, a failed post-deploy check can trigger a step that re-deploys the previous known-good tag. Keep deploys idempotent and fast to reverse.

The pattern is always: deploy → verify with a job that actually checks health → promote or roll back based on the result. Actions is the conductor; your deploy.sh/kubectl/Terraform does the work.

Debugging failing workflows

When a run fails, work through it systematically instead of pushing "fix" commits and waiting:

  • Read the failing step's log first — the actual error is almost always there, near the bottom.
  • Enable step debug logging by setting the repo secret ACTIONS_STEP_DEBUG=true for verbose output.
  • Reproduce locally with a tool like act, or replicate the exact commands in a container matching the runner image.
  • Re-run a single failed job rather than the whole workflow to shorten the loop.
  • Check the usual suspects: a missing permission on GITHUB_TOKEN, a secret that's empty on a forked PR, a cache key that changed, or an action pinned to a tag that moved. echo-ing non-secret context (${{ github.ref }}, ${{ github.event_name }}) quickly confirms why a conditional did or didn't run.

Troubleshooting: the errors you'll actually hit

  • Error: Credentials could not be loaded (OIDC) → you forgot permissions: id-token: write, or the cloud role's trust policy doesn't match your repo/branch. Check the role's condition.
  • Cache never hits → the cache key changed (lockfile hash) or you set cache: before the lockfile exists. Confirm package-lock.json is committed.
  • Resource not accessible by integration → the default GITHUB_TOKEN lacks a permission; add it under permissions: (e.g. packages: write to push to GHCR).
  • Secrets are empty in a PR from a fork → by design, forked PRs don't get your secrets. Use pull_request_target carefully, or run deploys only on push to main.
  • Slow builds → move the cheapest checks (lint, unit tests) first, cache dependencies and Docker layers, and split independent work into parallel jobs.

Reusable workflows, concurrency & job outputs

Once you have more than one repo, three features stop you copy-pasting YAML forever.

Reusable workflows let you define a pipeline once and call it from many repos. The shared workflow declares typed inputs and secrets, and callers pass them in:

# .github/workflows/deploy.yml (reusable)
on:
  workflow_call:
    inputs: {environment: {type: string, required: true}}
    secrets: {token: {required: true}}
# caller
jobs:
  ship:
    uses: my-org/ci-templates/.github/workflows/deploy.yml@v1
    with: {environment: production}
    secrets: {token: ${{ secrets.DEPLOY_TOKEN }}}

Concurrency prevents wasteful or dangerous overlapping runs. This cancels an in-progress run when you push again to the same branch — essential for deploys so two don't race:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

Job outputs and artifacts move data between jobs (which run on separate machines). Use outputs for small values (a computed version), and actions/upload-artifact / download-artifact for files like a build directory or test report. Without one of these, a later job cannot see an earlier job's work — a very common "why is my file missing?" surprise.

Finally, environments aren't just for approvals: they scope secrets. A secret defined on the production environment is only available to jobs that declare environment: production, so a test job physically cannot read your production credentials. That's least privilege enforced by the platform, not by discipline.

Good habits

  • Pin actions to a major version (@v4) and least-privilege the permissions: block.
  • Reusable workflows (workflow_call) keep many repos consistent.
  • Fail fast: cheapest checks first.

Where to go next

You now have the full arc: event → job → steps → secrets → OIDC → build → gated deploy. Our free end-to-end course builds this on a real repo and deploys it — keyless — to the cloud, with the lab environment and step-by-step guidance to make it stick.