CI/CD with GitHub Actions for Next.js and .NET - Complete Guide
CI/CD with GitHub Actions
CI/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.
