Skip to main content
devopsinfo.in
All articles
CI/CD7 min readUpdated

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 test

Four 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 test

fail-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_test

The 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:

  • outputs passes the image reference from build to both deploy jobs. They deploy the same digest; nothing is rebuilt.
  • needs creates the dependency graph. deploy-production waits for staging.
  • environment: production is 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.
  • permissions is 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:

  1. Never echo a secret, and never pass one as a command-line argument (arguments are visible in process listings on the runner).
  2. 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-1

The 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: true

Apply 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 (trivy for 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.