← All posts

A Practical CI/CD Setup with GitHub Actions

A walkthrough of the pipeline structure we use for .NET and Node projects: what runs on every PR, what runs on merge, and how we keep build times fast.

A good CI/CD pipeline is invisible when it works and immediately noticeable when it doesn’t. After setting these up for enough client projects, we’ve settled on a structure that balances speed, reliability, and maintainability.

The two-workflow model

We separate our pipelines into two GitHub Actions workflows:

ci.yml runs on every pull request. It’s fast — under three minutes is the target. Its job is to give the developer immediate feedback: does this compile, do the tests pass, does the linter complain?

cd.yml runs on merge to main. It builds the production artifact, pushes it to the registry, and deploys to the target environment.

This separation matters. The PR pipeline is a quality gate for the developer. The deploy pipeline is a mechanical process. Mixing them in one file leads to slow PRs or overly complex branching logic.

What ci.yml contains

name: CI

on:
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release --logger trx

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: '**/*.trx'

Three things worth noting:

--no-restore and --no-build pass explicit flags to avoid redundant work. Each step builds on the previous one; without these flags, dotnet test would quietly restore and rebuild.

if: always() on the test results upload ensures we get the artifact even when tests fail — which is exactly when you need it.

Branch filter on pull_request keeps the workflow from running on every push to every branch. Only PRs targeting main trigger it.

Caching dependencies

For a .NET project, the restore step can take 30–60 seconds on a cold runner. Caching the NuGet packages cuts this to under 5 seconds:

- name: Cache NuGet packages
  uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
    restore-keys: |
      ${{ runner.os }}-nuget-

The cache key includes a hash of all .csproj files. When dependencies change, the key changes and the cache is invalidated automatically.

What cd.yml contains

The deploy workflow builds a Docker image, pushes it to the container registry, and triggers a rollout. The exact deployment command depends on your target — kubectl set image, a Compose file update, or a platform-specific CLI.

One pattern we always include: deployment protection rules. In the GitHub repository settings, we add a required reviewer approval for the production environment. A merge to main triggers the workflow, but the deploy step waits for a human to approve it in the GitHub UI.

This gives you the automation of continuous delivery with a manual gate before production.

Keeping it fast

Our target: CI under 3 minutes, CD under 8 minutes. When either drifts past these, we investigate.

Common causes of slow pipelines:

  • Missing dependency caches (most common)
  • Running too many things in sequence that could be parallel
  • Integration tests running in the same job as unit tests (separate them)
  • Docker builds that don’t use layer caching

For Docker, use multi-stage builds and pin your base image tags. An unpinned FROM node:lts that pulls a new image every build will silently add minutes and introduce unexpected runtime changes.


The full workflow files we use are available on request. If you’re setting up CI/CD for a new project or cleaning up an existing pipeline, reach out.