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,
deploywaiting 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-goand friends have a built-incache:option keyed on your lockfile — turn it on. It restoresnode_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=ghareuses unchanged image layers across runs — often the single biggest speedup for containerized apps. - Build/tool caches. Use
actions/cachefor 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. Setpermissions:to the minimum (default read-only, grantpackages: writeetc. only where needed). - Guard secrets on forks. Forked-PR workflows don't get your secrets by design — don't defeat that
with
pull_request_targetunless 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
mainwith 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=truefor 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 forgotpermissions: 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. Confirmpackage-lock.jsonis committed. Resource not accessible by integration→ the defaultGITHUB_TOKENlacks a permission; add it underpermissions:(e.g.packages: writeto push to GHCR).- Secrets are empty in a PR from a fork → by design, forked PRs don't get your secrets. Use
pull_request_targetcarefully, or run deploys only onpushtomain. - 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 thepermissions: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.