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.
More Posts
Hello World: Welcome to the Constelutions Blog
Introducing our blog — a space to share insights on software development, cloud infrastructure, and building reliable systems for the long run.
Building Scalable APIs with Clean Architecture
How separating concerns across well-defined layers keeps your API maintainable as requirements evolve — and the concrete tradeoffs you'll make along the way.