CI/CD with GitHub Actions for Next.js and .NET - Complete Guide
CI/CD with GitHub
devopsCI/CD with GitHub Actions for Next.js and .NET
Automating the process of building, testing, and deploying applications is the cornerstone of modern software development. GitHub Actions, built directly into the GitHub platform, enables you to create sophisticated CI/CD pipelines without configuring external tools. In this guide, we will walk through setting up complete workflows for Next.js and .NET projects — from the first commit to production deployment.
What is CI/CD?#
Continuous Integration (CI) is the practice of frequently merging code changes into the main branch of a repository. Each change is automatically verified through automated tests and build processes, enabling early detection of bugs and integration issues.
Continuous Delivery / Continuous Deployment (CD) extends CI with automated application deployment. Continuous Delivery means every change that passes tests is ready for deployment (but requires manual approval). Continuous Deployment takes it a step further — automatically deploying every change that passes the pipeline.
Benefits of implementing CI/CD:
- Faster bug detection — issues are identified immediately after each commit
- Shorter delivery times — from commit to production in minutes
- Higher code quality — automated tests and linting on every change
- Reproducibility — every deployment follows the exact same process
- Lower risk — small, frequent changes instead of large, risky deployments
GitHub Actions — Platform Overview#
GitHub Actions is a CI/CD platform integrated directly into GitHub. It offers:
- Free minutes for public repositories (unlimited) and private repositories (2,000 min/month on the Free plan)
- Hosted runners — virtual machines running Linux, Windows, and macOS
- Self-hosted runners — the ability to run workflows on your own infrastructure
- Marketplace — thousands of pre-built actions created by the community
- GitHub integration — native support for pull requests, issues, and deployments
Workflow Syntax — Anatomy of a YAML File#
Every GitHub Actions workflow is a YAML file placed in the .github/workflows/ directory. Here are the key elements of the syntax:
# .github/workflows/ci.yml
name: CI Pipeline # Workflow name
on: # Triggers
push:
branches: [main, develop] # Run on push to main/develop
pull_request:
branches: [main] # Run on PR to main
workflow_dispatch: # Allow manual triggering
env: # Environment variables (global)
NODE_VERSION: "20"
DOTNET_VERSION: "8.0.x"
jobs: # Job definitions
build: # Job name
runs-on: ubuntu-latest # Runner
timeout-minutes: 15 # Timeout
steps: # Job steps
- name: Checkout code
uses: actions/checkout@v4 # Use a pre-built action
- name: Run custom script
run: echo "Hello CI/CD!" # Run a shell command
Triggers#
GitHub Actions supports many types of triggers:
on:
push:
branches: [main, develop]
paths:
- "src/**" # Only when files in src/ change
- "!docs/**" # Ignore changes in docs/
tags:
- "v*" # Run on tags like v1.0, v2.1 etc.
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
schedule:
- cron: "0 6 * * 1" # Every Monday at 6:00 AM UTC
workflow_dispatch: # Manual triggering
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- production
Jobs and Steps#
Jobs run in parallel by default. You can define dependencies between them:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
test:
runs-on: ubuntu-latest
needs: lint # Run after lint completes
steps:
- uses: actions/checkout@v4
- run: npm test
deploy:
runs-on: ubuntu-latest
needs: [lint, test] # Run after lint AND test
if: github.ref == 'refs/heads/main' # Only on main branch
steps:
- run: echo "Deploying..."
Next.js CI/CD Workflow#
The following workflow covers a complete pipeline for a Next.js application — from linting to Vercel deployment:
# .github/workflows/nextjs-ci-cd.yml
name: Next.js CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: "20"
jobs:
# ─── Stage 1: Lint ──────────────────────────
lint:
name: Lint & Format Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Check Prettier formatting
run: npx prettier --check "src/**/*.{ts,tsx,js,jsx}"
- name: TypeScript type check
run: npx tsc --noEmit
# ─── Stage 2: Tests ─────────────────────────
test:
name: Unit & Integration Tests
runs-on: ubuntu-latest
needs: lint
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test -- --coverage --ci
env:
CI: true
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
# ─── Stage 3: Build ─────────────────────────
build:
name: Build Application
runs-on: ubuntu-latest
needs: test
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build Next.js application
run: npm run build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: nextjs-build
path: .next/
retention-days: 1
# ─── Stage 4: Deploy to Vercel ──────────────
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Deploy to Vercel (Preview)
id: deploy
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
scope: ${{ secrets.VERCEL_ORG_ID }}
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment:
name: production
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Deploy to Vercel (Production)
id: deploy
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: "--prod"
scope: ${{ secrets.VERCEL_ORG_ID }}
.NET CI/CD Workflow#
Here is a complete pipeline for a .NET application, covering building, testing, and publishing:
# .github/workflows/dotnet-ci-cd.yml
name: .NET CI/CD
on:
push:
branches: [main, develop]
paths:
- "src/**"
- "tests/**"
- "*.sln"
- "*.csproj"
pull_request:
branches: [main]
env:
DOTNET_VERSION: "8.0.x"
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
SOLUTION_PATH: "./MyApp.sln"
jobs:
# ─── Stage 1: Build & Test ──────────────────
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore NuGet packages
run: dotnet restore ${{ env.SOLUTION_PATH }}
- name: Build solution
run: dotnet build ${{ env.SOLUTION_PATH }} --configuration Release --no-restore
- name: Run unit tests
run: |
dotnet test ${{ env.SOLUTION_PATH }} \
--configuration Release \
--no-build \
--verbosity normal \
--collect:"XPlat Code Coverage" \
--results-directory ./test-results
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: ./test-results/
retention-days: 7
# ─── Stage 2: Code Analysis ─────────────────
code-analysis:
name: Code Analysis
runs-on: ubuntu-latest
needs: build-and-test
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Run dotnet format check
run: dotnet format ${{ env.SOLUTION_PATH }} --verify-no-changes --verbosity diagnostic
# ─── Stage 3: Publish ───────────────────────
publish:
name: Publish Application
runs-on: ubuntu-latest
needs: [build-and-test, code-analysis]
if: github.ref == 'refs/heads/main'
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Publish application
run: |
dotnet publish src/MyApp.Api/MyApp.Api.csproj \
--configuration Release \
--output ./publish \
--self-contained false \
--runtime linux-x64
- name: Upload published artifacts
uses: actions/upload-artifact@v4
with:
name: dotnet-publish
path: ./publish/
retention-days: 5
Docker Build and Push#
Building and publishing Docker images is a key element of any CD pipeline. Here is a workflow that builds an image and pushes it to GitHub Container Registry (GHCR):
# .github/workflows/docker-build.yml
name: Docker Build & Push
on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_VERSION=${{ github.sha }}
Caching Dependencies#
Proper caching can reduce pipeline execution time by up to 70%. Here are strategies for different technologies:
Node.js / Next.js Cache#
- name: Setup Node.js with cache
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm" # Automatic node_modules cache
# Or manual caching for greater control:
- name: Cache Node modules
uses: actions/cache@v4
id: npm-cache
with:
path: |
~/.npm
.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
${{ runner.os }}-nextjs-
.NET Cache#
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
Docker Cache#
- name: Build with GitHub Actions cache
uses: docker/build-push-action@v5
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
Environment Variables and Secrets#
GitHub Actions offers several levels for managing variables and secrets:
Configuration Levels#
# Workflow level (available in all jobs)
env:
APP_NAME: "my-application"
jobs:
deploy:
# Job level
env:
DEPLOY_ENV: "staging"
steps:
- name: Build
# Step level
env:
API_KEY: ${{ secrets.API_KEY }}
run: echo "Building for $APP_NAME in $DEPLOY_ENV"
Environments#
GitHub Environments allow you to define secrets and protection rules per environment:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # Secret from staging environment
API_KEY: ${{ secrets.API_KEY }}
run: ./deploy.sh
deploy-production:
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
needs: deploy-staging
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # Secret from production environment
run: ./deploy.sh
Deployment Strategies — Staging and Production#
A well-designed pipeline should support multiple environments with appropriate security gates:
# .github/workflows/deploy.yml
name: Deploy Pipeline
on:
push:
branches: [main, develop]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
deploy-staging:
name: Deploy to Staging
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.myapp.com
steps:
- uses: actions/checkout@v4
- name: Deploy to staging server
run: |
echo "Deploying to staging..."
# Staging deployment command
env:
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
deploy-production:
name: Deploy to Production
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment:
name: production # Requires manual approval (configured in repo settings)
url: https://myapp.com
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying to production..."
# Production deployment command
env:
DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}
- name: Notify on success
if: success()
run: |
curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
-H "Content-Type: application/json" \
-d '{"text":"Deployment to production successful! Commit: ${{ github.sha }}"}'
Branch Protection Rules and Status Checks#
Branch protection is a critical element of pipeline security. Configure it in repository settings (Settings > Branches):
Recommended rules for the main branch:
- Require pull request reviews — require at least 1-2 reviewers
- Require status checks to pass — mark the CI workflow as required
- Require branches to be up to date — enforce rebase/merge with main before merging
- Require signed commits — optional, for additional security
- Do not allow bypassing — even admins must follow the rules
Status checks integrate directly with pull requests:
# This job will appear as a required status check
jobs:
ci:
name: "CI / Build & Test" # This name will appear in PRs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
Reusable Workflows#
Instead of copying configuration between repositories, create reusable workflows:
# .github/workflows/reusable-nextjs-ci.yml
name: Reusable Next.js CI
on:
workflow_call: # Allow calling from other workflows
inputs:
node-version:
description: "Node.js version"
required: false
default: "20"
type: string
run-e2e:
description: "Run E2E tests"
required: false
default: false
type: boolean
secrets:
VERCEL_TOKEN:
required: false
SONAR_TOKEN:
required: false
jobs:
ci:
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 run lint
- run: npm test -- --ci
- name: E2E Tests
if: inputs.run-e2e
run: npx playwright test
Calling a reusable workflow:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
nextjs-ci:
uses: ./.github/workflows/reusable-nextjs-ci.yml
with:
node-version: "20"
run-e2e: true
secrets:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
Matrix Builds — Testing Across Multiple Configurations#
The matrix strategy allows you to run the same job across multiple configurations simultaneously:
jobs:
test:
name: Test on Node ${{ matrix.node-version }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Don't cancel other jobs on failure
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
exclude:
- os: windows-latest
node-version: 18 # Skip this combination
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
test-dotnet:
name: .NET Test on ${{ matrix.dotnet-version }}
runs-on: ubuntu-latest
strategy:
matrix:
dotnet-version: ["7.0.x", "8.0.x"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet-version }}
- run: dotnet test --configuration Release
Complete Pipeline — Next.js + .NET in a Single Repository#
If your project is a fullstack application with Next.js (frontend) and .NET (backend API), here is a combined workflow:
# .github/workflows/fullstack-ci-cd.yml
name: Fullstack CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# ─── Frontend (Next.js) ─────────────────────
frontend-ci:
name: Frontend CI
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm test -- --ci
- run: npm run build
# ─── Backend (.NET) ─────────────────────────
backend-ci:
name: Backend CI
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- run: dotnet restore
- run: dotnet build --configuration Release --no-restore
- run: dotnet test --configuration Release --no-build
# ─── Docker Build ───────────────────────────
docker-build:
name: Docker Build
needs: [frontend-ci, backend-ci]
runs-on: ubuntu-latest
if: github.event_name == 'push'
strategy:
matrix:
include:
- context: ./frontend
image: frontend
dockerfile: ./frontend/Dockerfile
- context: ./backend
image: backend
dockerfile: ./backend/Dockerfile
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: true
tags: ghcr.io/${{ github.repository }}/${{ matrix.image }}:${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.image }}
cache-to: type=gha,scope=${{ matrix.image }},mode=max
# ─── Deploy ─────────────────────────────────
deploy:
name: Deploy
needs: docker-build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Deploy to production
run: |
echo "Deploying frontend and backend..."
# kubectl set image deployment/frontend ...
# kubectl set image deployment/backend ...
Best Practices#
- Pin action versions — use
actions/checkout@v4instead ofactions/checkout@main - Limit permissions — set
permissionsto the minimum required level - Cache dependencies — use
actions/cacheor built-in caching insetup-node/setup-dotnet - Set timeouts —
timeout-minutesprevents hung jobs from consuming resources - Filter paths — trigger workflows only when relevant files change
- Use environments — separate secrets for staging and production
- Monitor execution time — optimize pipelines that exceed 10 minutes
- Version your workflows — treat YAML files like production code
Conclusion#
GitHub Actions is a powerful CI/CD platform that, combined with Next.js and .NET, enables you to create a complete, automated pipeline from commit to production deployment. The key elements are proper caching, deployment strategies with environment separation, branch protection, and reusable workflows.
Implementing a well-designed CI/CD pipeline is an investment that pays for itself many times over — through faster deployments, higher code quality, and a more peaceful sleep for the development team.
Need professional CI/CD configuration for your project? At MDS Software Solutions Group, we design and implement advanced CI/CD pipelines for Next.js, .NET, and beyond. From simple configurations to complex, multi-environment deployments with Docker and Kubernetes — we will help you automate your entire software delivery process. Contact us to discuss your DevOps needs.
Team of programming experts specializing in modern web technologies.