DevOps from Zero to Hero: Your First CI Pipeline with GitHub Actions

2026-05-03 | Gabriel Garrido | 17 min read
Share:

Support this blog

If you find this content useful, consider supporting the blog.

Introduction

Welcome to article five of the DevOps from Zero to Hero series. In the previous article we wrote unit and integration tests for a TypeScript project. Tests are great, but they only help if someone actually runs them. That someone should not be you, manually, right before a deploy. It should be a machine that runs them every single time code changes.


That is what Continuous Integration (CI) is about: automating the boring, repetitive, critical stuff so humans can focus on writing code. In this article we are going to build a complete CI pipeline with GitHub Actions from scratch. By the end, every push and pull request to your repository will automatically lint the code, run tests, build a Docker image, and push it to a container registry.


Let’s get into it.


What is CI and why it matters

Continuous Integration is the practice of automatically building and testing code every time someone pushes a change. The word “continuous” is important: this is not something you do once a week or before a release. It happens on every commit, every pull request, every time.


Why does this matter? Three reasons:


  • Catch bugs early: A bug found in CI costs minutes to fix. A bug found in production costs hours, customer trust, and sometimes money. The earlier you catch it, the cheaper it is.
  • Enforce standards: Linting, formatting, and type checking should not depend on developers remembering to run them. CI enforces these standards automatically, every time.
  • Automate repetitive tasks: Building Docker images, running test suites, generating artifacts. These are things a machine should do, not a person.

Without CI, your workflow looks like this: a developer writes code, forgets to run the linter, pushes to main, breaks the build, and the whole team notices an hour later. With CI, the linter runs automatically, the push is blocked, and the developer fixes it in five minutes before anyone else is affected.


CI is the first real automation layer in a DevOps pipeline. Everything else, continuous delivery, continuous deployment, infrastructure as code, all of it builds on top of CI.


GitHub Actions fundamentals

GitHub Actions is a CI/CD platform built into GitHub. You define workflows as YAML files in a .github/workflows/ directory, and GitHub runs them for you on hosted virtual machines. There is no separate service to set up, no webhooks to configure, and no servers to manage.


Before we write any YAML, let’s understand the key concepts:


  • Workflow: A YAML file that defines an automated process. Each workflow lives in .github/workflows/ and is triggered by events.
  • Event (trigger): What causes the workflow to run. Common triggers are push, pull_request, and schedule.
  • Job: A set of steps that run on the same virtual machine (called a “runner”). A workflow can have multiple jobs, and by default they run in parallel.
  • Step: A single task within a job. A step can run a shell command or use a pre-built action.
  • Action: A reusable unit of code that performs a common task. For example, actions/checkout@v4 clones your repository, and actions/setup-node@v4 installs Node.js.
  • Runner: The virtual machine that executes your job. GitHub provides hosted runners with Ubuntu, Windows, and macOS.

Here is the hierarchy visualized:


Workflow (.github/workflows/ci.yml)
  ├── Event: push to main, pull_request
  ├── Job: lint
  │     ├── Step: Checkout code
  │     ├── Step: Setup Node.js
  │     └── Step: Run ESLint
  ├── Job: test
  │     ├── Step: Checkout code
  │     ├── Step: Setup Node.js
  │     ├── Step: Install dependencies
  │     └── Step: Run Vitest
  └── Job: build
        ├── Step: Checkout code
        ├── Step: Setup Docker Buildx
        └── Step: Build and push image

Triggers: when does CI run?

The on key in your workflow file defines when it runs. Here are the triggers you will use most often:


# Run on every push to main
on:
  push:
    branches: [main]

# Run on every pull request targeting main
on:
  pull_request:
    branches: [main]

# Run on both push and pull request
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Run on a schedule (cron syntax, every day at 6 AM UTC)
on:
  schedule:
    - cron: "0 6 * * *"

# Run manually from the GitHub UI
on:
  workflow_dispatch:

For a typical project, you want CI to run on both push and pull_request to the main branch. The push trigger catches anything that lands on main directly, and the pull request trigger gives you feedback before merging.


Building the pipeline step by step

Let’s build a real CI pipeline for a TypeScript project. We will start simple and add features incrementally. Create the file .github/workflows/ci.yml in your repository:


# .github/workflows/ci.yml
name: CI

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

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npx eslint . --max-warnings 0

  test:
    name: Test
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run tests with coverage
        run: npm run test:coverage

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

Let’s break down what is happening here:


  • actions/checkout@v4: Clones your repository into the runner. Without this, the runner has no code to work with.
  • actions/setup-node@v4: Installs the specified Node.js version and configures npm caching.
  • npm ci: Installs dependencies from package-lock.json exactly as specified. Unlike npm install, it does not modify the lockfile and is faster and more reliable in CI.
  • npx eslint . --max-warnings 0: Runs ESLint and fails if there are any warnings. This is stricter than the default, which only fails on errors. Treating warnings as errors in CI prevents them from piling up.
  • Lint and test jobs run in parallel: Since they do not depend on each other, GitHub runs them at the same time, making your pipeline faster.

Adding the Docker build

Now let’s add a job that builds a Docker image and pushes it to GitHub Container Registry (GHCR). This job should only run after linting and tests pass, so we use the needs keyword to create a dependency:


  build:
    name: Build and Push Docker Image
    runs-on: ubuntu-latest
    needs: [lint, test]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

There is a lot going on here, so let’s unpack it:


  • needs: [lint, test]: This job waits for both lint and test to pass before running. If either fails, the build is skipped entirely.
  • if: github.event_name == 'push' && github.ref == 'refs/heads/main': Only build images on pushes to main, not on pull requests. You do not want to push a Docker image for every PR.
  • permissions: GitHub Actions uses a GITHUB_TOKEN that is automatically created for each workflow run. We need packages: write to push to GHCR.
  • docker/setup-buildx-action@v3: Sets up Docker Buildx, which is an extended build tool that supports advanced features like caching and multi-platform builds.
  • docker/login-action@v3: Logs into GHCR using the built-in GITHUB_TOKEN. No need to create a personal access token.
  • docker/metadata-action@v5: Generates tags and labels automatically. We tag with both the Git SHA (for traceability) and latest (for convenience).
  • docker/build-push-action@v6: Builds the Dockerfile and pushes the image. The cache-from and cache-to lines enable GitHub Actions cache for Docker layers, which we will explain next.

Caching: making CI fast

CI pipelines that take 10 minutes quickly become a bottleneck. Developers stop waiting for them, start merging without checking results, and the whole point of CI breaks down. Caching is how you keep things fast.


There are two things worth caching in a Node.js project: npm packages and Docker layers.


npm cache is the easier one. The actions/setup-node@v4 action handles it for you when you add cache: "npm":


      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

This caches the npm download cache (not node_modules), so npm ci still runs but does not need to download packages from the registry. The first run populates the cache, and subsequent runs reuse it. On a project with many dependencies, this can save 30 to 60 seconds per run.


Docker layer cache is more impactful. Building a Docker image from scratch every time is wasteful because most layers (like the base image and installed system packages) rarely change. Docker Buildx with the GitHub Actions cache backend stores layers between runs:


      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  • cache-from: type=gha: Pull cached layers from the GitHub Actions cache.
  • cache-to: type=gha,mode=max: Push all layers to the cache after building. The mode=max option caches intermediate layers too, not just the final image layers.

A well-structured Dockerfile benefits enormously from this. If your dependency installation layer has not changed, Docker reuses the cached layer instead of running npm ci again inside the container. This can cut build times from minutes to seconds.


Matrix builds: testing across versions

Sometimes you need to test your code against multiple Node.js versions, or multiple operating systems, or both. Matrix builds let you define a set of variables and run the job once for each combination.


  test:
    name: Test (Node ${{ matrix.node-version }})
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: ["20", "22"]
      fail-fast: false

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

This runs the test job twice: once with Node 20 and once with Node 22. Both runs happen in parallel on separate runners, so it does not slow down your pipeline.


Key settings:


  • strategy.matrix: Defines the variables and their values. You can add more dimensions, like os: [ubuntu-latest, windows-latest], and GitHub will run every combination.
  • fail-fast: false: By default, if one matrix job fails, GitHub cancels the others. Setting this to false lets all jobs complete, so you can see all failures at once.

Matrix builds are especially useful for libraries that need to support multiple runtimes. For application code where you control the runtime, testing a single version is usually enough.


Secrets and environment variables

Your CI pipeline will often need credentials: API keys for external services, tokens for registries, or database passwords for integration tests. GitHub provides two mechanisms for this.


Environment variables are for non-sensitive values:


    env:
      NODE_ENV: test
      API_URL: https://api.staging.example.com

    steps:
      - name: Run tests
        run: npm test
        env:
          DATABASE_URL: postgres://localhost:5432/testdb

You can set environment variables at the workflow level, job level, or step level. Step-level variables override job-level variables, which override workflow-level variables.


Secrets are for sensitive values like API keys and tokens:


      - name: Deploy to staging
        run: ./deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

To add secrets, go to your repository’s Settings, then Secrets and variables, then Actions. Secrets are encrypted at rest and masked in logs. GitHub will replace the secret value with *** if it accidentally appears in the output.


Important rules about secrets:


  • Never hardcode secrets in your workflow files. They are committed to the repository and visible to anyone with read access.
  • GITHUB_TOKEN is automatic. You do not need to create it. GitHub generates one for every workflow run with permissions scoped to the repository.
  • Secrets are not available in pull requests from forks. This is a security feature. If your tests need secrets, they will fail on fork PRs, which is expected.
  • Use environments for deployment secrets. GitHub environments let you require approvals and restrict which branches can use certain secrets.

Reusable workflows: keeping things DRY

As your organization grows, you will have multiple repositories that need similar CI pipelines. Copy pasting YAML files between repositories is a maintenance nightmare. Reusable workflows let you define a workflow once and call it from other workflows.


First, create the reusable workflow in a shared repository. The key difference is the workflow_call trigger:


# .github/workflows/node-ci.yml (in your shared repo)
name: Node.js CI

on:
  workflow_call:
    inputs:
      node-version:
        description: "Node.js version to use"
        required: false
        type: string
        default: "22"
      run-lint:
        description: "Whether to run linting"
        required: false
        type: boolean
        default: true

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    if: ${{ inputs.run-lint }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: "npm"

      - run: npm ci

      - run: npx eslint . --max-warnings 0

  test:
    name: Test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: "npm"

      - run: npm ci

      - run: npm test

Then call it from any repository:


# .github/workflows/ci.yml (in your project repo)
name: CI

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

jobs:
  ci:
    uses: your-org/shared-workflows/.github/workflows/node-ci.yml@main
    with:
      node-version: "22"
      run-lint: true

The benefits are significant:


  • Single source of truth: Update the shared workflow and every repository that uses it gets the update.
  • Consistency: Every project follows the same CI process, same actions versions, same caching strategy.
  • Less maintenance: Fix a bug or upgrade an action in one place, not in fifty repositories.
  • Inputs make it flexible: Each project can customize behavior (Node version, whether to lint, etc.) without forking the workflow.

Status badges: show your pipeline health

Once your CI pipeline is working, you want everyone to see its status at a glance. GitHub provides status badges that you can add to your README:


![CI](https://github.com/your-org/your-repo/actions/workflows/ci.yml/badge.svg)

This renders as a small badge that shows “passing” (green) or “failing” (red) based on the latest run of the workflow. Add it to the top of your README so contributors immediately know the project’s health.


You can also make badges branch-specific:


![CI](https://github.com/your-org/your-repo/actions/workflows/ci.yml/badge.svg?branch=main)

This only reflects the status of the workflow on the main branch, ignoring feature branches.


Branch protection: require CI to pass before merge

A CI pipeline is only useful if people cannot bypass it. Branch protection rules ensure that code cannot be merged into main unless CI passes. Here is how to set it up:


  1. Go to your repository’s Settings, then Branches.
  2. Click “Add branch protection rule” (or “Add classic branch protection rule”).
  3. Set the branch name pattern to main.
  4. Check “Require status checks to pass before merging.”
  5. Search for and select your CI job names (e.g., “Lint”, “Test”).
  6. Optionally check “Require branches to be up to date before merging” to prevent merging stale branches.

With this in place, the merge button on a pull request is disabled until all required checks pass. No one can bypass CI, not even repository admins (unless they explicitly override it, which leaves an audit trail).


Additional protections worth enabling:


  • Require pull request reviews: At least one team member must approve before merging.
  • Require linear history: Force squash or rebase merges for a clean git history.
  • Do not allow bypassing the above settings: Even admins must follow the rules.

The complete workflow file

Here is the full CI pipeline combining everything we covered. This is a production-ready starting point for any TypeScript project:


# .github/workflows/ci.yml
name: CI

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

env:
  NODE_ENV: test
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Check formatting
        run: npx prettier --check .

      - name: Run ESLint
        run: npx eslint . --max-warnings 0

      - name: Type check
        run: npx tsc --noEmit

  test:
    name: Test (Node ${{ matrix.node-version }})
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: ["20", "22"]
      fail-fast: false

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run tests with coverage
        run: npm run test:coverage

      - name: Upload coverage report
        if: matrix.node-version == '22'
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 14

  build:
    name: Build and Push Docker Image
    runs-on: ubuntu-latest
    needs: [lint, test]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Notice a few things about this complete workflow:


  • Three stages: Lint, test, and build. They form a pipeline where each stage gates the next.
  • Type checking in lint: We added tsc --noEmit to catch TypeScript errors. This is a cheap check that catches a whole class of bugs.
  • Prettier check: prettier --check verifies formatting without modifying files. If a developer forgot to format, CI catches it.
  • Coverage only uploaded once: When running a matrix build, you only need one coverage report, not one per Node version. The if: matrix.node-version == '22' conditional handles this.
  • Retention days: Artifacts do not need to live forever. Setting retention-days: 14 keeps things tidy.
  • Environment variables at the top: REGISTRY and IMAGE_NAME are defined once and reused, making the workflow easier to adapt to other registries.

Debugging failed workflows

When your CI pipeline fails (and it will), here is how to debug it:


  • Read the logs: Click on the failed job in the GitHub Actions UI. Each step shows its output. The error is usually in the last few lines of the failed step.
  • Run locally first: Before pushing, run the same commands locally. npm ci && npx eslint . && npm test should produce the same result as CI.
  • Check the runner environment: CI runs on a clean Ubuntu machine. If something works locally but fails in CI, the difference is usually in environment variables, installed tools, or file paths.
  • Use act for local testing: The act tool (https://github.com/nektos/act) lets you run GitHub Actions workflows on your local machine using Docker. It is not perfect, but it catches most issues.
  • Enable debug logging: Re-run the workflow with debug logging enabled by going to the failed run, clicking “Re-run all jobs”, and checking “Enable debug logging.” This adds verbose output from every action.

Common pitfalls and how to avoid them

A few things that trip people up when setting up CI for the first time:


  • Not using npm ci: Using npm install in CI can produce different dependency trees than your local machine. Always use npm ci, which installs exactly what is in package-lock.json.
  • Missing package-lock.json in the repository: If you gitignored it, npm ci will fail. The lockfile should always be committed.
  • Tests that depend on order: If your tests pass locally but fail in CI, they might depend on execution order. Vitest runs tests in parallel by default, which can expose this.
  • Hardcoded paths: Tests that reference /Users/yourname/project/ will fail on a Linux runner. Use relative paths or environment variables.
  • Forgetting the Docker context: If your Dockerfile copies files with COPY . ., make sure your .dockerignore excludes node_modules, .git, and other large directories.
  • Overly broad triggers: Running CI on every push to every branch wastes runner minutes. Limit triggers to main and pull requests targeting main.

What comes next

We now have a CI pipeline that lints, tests, and builds our code automatically. But CI is only half the story. Getting code into a container is useful, but that container needs to go somewhere.


In the next article, we will tackle Continuous Deployment (CD): taking the Docker image we just built and deploying it to a real environment. We will cover deployment strategies, rollbacks, and how to make deployments boring (which is exactly what you want them to be).


Hope you found this useful and enjoyed reading it, until next time!


Errata

If you spot any error or have any suggestion, please send me a message so it gets fixed.

Also, you can check the source code and changes in the sources here



$ Comments

Online: 0

Please sign in to be able to write comments.

2026-05-03 | Gabriel Garrido