DevOps from Zero to Hero: Your First CI Pipeline with GitHub Actions
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, andschedule.- 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@v4clones your repository, andactions/setup-node@v4installs 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 frompackage-lock.jsonexactly as specified. Unlikenpm 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 aGITHUB_TOKENthat is automatically created for each workflow run. We needpackages: writeto 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-inGITHUB_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) andlatest(for convenience).docker/build-push-action@v6: Builds the Dockerfile and pushes the image. Thecache-fromandcache-tolines 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. Themode=maxoption 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, likeos: [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 tofalselets 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_TOKENis 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:

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:

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:
- Go to your repository’s Settings, then Branches.
- Click “Add branch protection rule” (or “Add classic branch protection rule”).
- Set the branch name pattern to
main.- Check “Require status checks to pass before merging.”
- Search for and select your CI job names (e.g., “Lint”, “Test”).
- 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 --noEmitto catch TypeScript errors. This is a cheap check that catches a whole class of bugs.- Prettier check:
prettier --checkverifies 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: 14keeps things tidy.- Environment variables at the top:
REGISTRYandIMAGE_NAMEare 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 testshould 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
actfor local testing: Theacttool (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: Usingnpm installin CI can produce different dependency trees than your local machine. Always usenpm ci, which installs exactly what is inpackage-lock.json.- Missing
package-lock.jsonin the repository: If you gitignored it,npm ciwill 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.dockerignoreexcludesnode_modules,.git, and other large directories.- Overly broad triggers: Running CI on every push to every branch wastes runner minutes. Limit triggers to
mainand pull requests targetingmain.
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: 0Please sign in to be able to write comments.