Przejdź do treści
DevOps

CI/CD with GitHub Actions for Next.js and .NET - Complete Guide

Published on:
·5 min read·Author: MDS Software Solutions Group

CI/CD with GitHub

devops

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#

  1. Pin action versions — use actions/checkout@v4 instead of actions/checkout@main
  2. Limit permissions — set permissions to the minimum required level
  3. Cache dependencies — use actions/cache or built-in caching in setup-node/setup-dotnet
  4. Set timeoutstimeout-minutes prevents hung jobs from consuming resources
  5. Filter paths — trigger workflows only when relevant files change
  6. Use environments — separate secrets for staging and production
  7. Monitor execution time — optimize pipelines that exceed 10 minutes
  8. 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.

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

CI/CD with GitHub Actions for Next.js and .NET - Complete Guide | MDS Software Solutions Group | MDS Software Solutions Group