A Beginner-Friendly Guide to CI/CD with GitHub Actions
Build a pipeline you can explain: what CI and CD actually mean, how a GitHub Actions workflow is structured, and how to get from a test run to a safe deployment.
Most CI/CD tutorials hand you a 60-line YAML file and move on. You end up with a pipeline that works and that you cannot change, because you do not know which parts are load-bearing.
This guide builds one up instead — starting from the smallest workflow that does anything useful, and adding one concept at a time until you have something you would be comfortable deploying with.
What the two halves actually mean
Continuous Integration is the discipline of merging small changes often, with automated checks proving each one does not break the build. The automation is the visible part; the small and often part is what actually delivers the benefit.
Continuous Delivery means every change that passes those checks is in a deployable state — an artifact exists, it has been tested, and shipping it is a decision rather than a project.
Continuous Deployment goes one step further: passing changes deploy automatically, with no human approval.
Most teams should want CI plus Continuous Delivery, and should adopt Continuous Deployment only once their tests genuinely justify that trust. There is no prize for skipping the middle step.
The vocabulary, in one place
A GitHub Actions workflow has four nested concepts:
- A workflow is a YAML file in
.github/workflows/. It is triggered by an event. - A job is a set of steps that run on one machine (a “runner”). Jobs run in parallel unless you declare dependencies.
- A step is a single command or a reusable action.
- An action is a packaged step someone else wrote, referenced as
owner/repo@version.
Jobs are the isolation boundary. Two jobs do not share a filesystem, so anything one job produces that another needs must be passed as an artifact or rebuilt.
The smallest useful workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm testFour things are worth understanding before adding anything else.
on: declares the triggers. pull_request runs checks before a merge — that is where CI earns its keep. push on main catches anything that reached the branch another way.
actions/checkout is not optional. The runner starts empty; without this step there is no code.
cache: 'npm' in setup-node caches the npm download cache keyed on your lockfile. This is the single highest-value line in the file for pipeline speed, and it is one word.
npm ci rather than npm install. ci installs exactly what the lockfile says and fails if package.json and the lockfile disagree. install will happily update the lockfile, which means CI tested something different from what you committed.
Adding a matrix
Test against several versions without duplicating the job:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ['20', '22', '24']
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm testfail-fast: false is a deliberate choice. The default cancels every matrix job as soon as one fails, which is efficient and unhelpful — you usually want to know whether the failure is on one version or all three.
Services: testing against a real database
Mocked databases catch fewer bugs than real ones. GitHub Actions can run containers alongside your job:
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: ci-only-password
MYSQL_DATABASE: app_test
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- run: npm test
env:
DATABASE_URL: mysql://root:ci-only-password@127.0.0.1:3306/app_testThe options block is what makes this reliable. Without a health check the job races the database’s startup, and you get a test suite that fails maybe one run in five — the worst kind of failure, because people learn to re-run it instead of fixing it.
Build once, deploy that exact thing
Here is the idea that separates a pipeline you trust from one you merely run.
A common mistake is to build the application separately for staging and for production. The moment you do that, “it passed in staging” stops being evidence about production, because production is running a different build.
Instead: build one artifact, tag it with the commit SHA, and promote that same digest through every environment.
name: Delivery
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
run: echo "image=ghcr.io/${{ github.repository }}:${{ github.sha }}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.image }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- run: ./scripts/deploy.sh "${{ needs.build.outputs.image }}"
deploy-production:
needs: [build, deploy-staging]
runs-on: ubuntu-latest
environment: production
steps:
- run: ./scripts/deploy.sh "${{ needs.build.outputs.image }}"The important mechanics:
outputspasses the image reference frombuildto both deploy jobs. They deploy the same digest; nothing is rebuilt.needscreates the dependency graph.deploy-productionwaits for staging.environment: productionis the approval gate. Configure required reviewers on that environment in repository settings and the job pauses until someone approves it. This is how you get Continuous Delivery without Continuous Deployment.permissionsis set explicitly. Give the token the narrowest set that works.
Secrets, and the thing to do instead of secrets
Repository secrets are available as ${{ secrets.NAME }} and are masked in logs. Two rules cover most of the risk:
- Never
echoa secret, and never pass one as a command-line argument (arguments are visible in process listings on the runner). - Prefer OIDC over long-lived credentials for cloud deployments.
OIDC means GitHub vouches for the workflow’s identity and your cloud provider issues a short-lived token. There is no static key to leak or rotate:
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: eu-central-1The trust policy on that IAM role restricts which repository and which branch may assume it. Getting that condition right is the actual security control — an over-broad trust policy lets any repository in your org deploy to your account.
Keeping it fast
Slow pipelines get bypassed, and a bypassed pipeline protects nothing.
Cache dependencies. setup-node, setup-python and friends all take a cache input. Use it.
Cache Docker layers with cache-from/cache-to: type=gha, as above.
Parallelise independent work. Lint, unit tests and type-checking do not need each other; give them separate jobs.
Cancel superseded runs. Pushing three times to a PR should not run three full pipelines:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueApply that on PR workflows. Be careful applying it to deployment workflows — cancelling a deploy halfway through is rarely what you want.
Debugging when it fails
Read the actual failure, not the summary. Expand the failing step. The red X at the top tells you nothing you did not already know.
Reproduce the environment locally. ubuntu-latest has a documented list of pre-installed software; a lot of “works on my machine” comes from a tool present locally and absent on the runner, or vice versa.
Enable debug logging. Set the repository secret ACTIONS_STEP_DEBUG to true for verbose output from actions themselves.
Suspect caching. If a failure appeared with no relevant code change, a stale cache is a strong candidate. Change the cache key to bust it and see whether the failure survives.
Where to go next
Once the basics are in place, the highest-value additions are usually:
- A security scan in the pipeline (
trivyfor images, dependency review for packages). - Required status checks on the default branch, so a failing pipeline actually blocks a merge. Without this, CI is advisory.
- Reusable workflows (
workflow_call) once you have more than two repositories doing similar things.
Start with the smallest workflow that runs your tests on every pull request. That single step catches more bugs than every sophisticated addition that follows it.