DevOps & Cloud

CI/CD Pipeline: Automated Testing & Deployment with GitHub Actions

10 min readMarch 17, 2026
CI/CD pipelineGitHub ActionsCI/CD rehberiOtomatik deploymentGitHub Actions tutorialDevOpsContinuous integrationContinuous deploymentGitHub Actions DockerCI/CD .NET

CI/CD (Continuous Integration / Continuous Deployment) is one of the most critical components of the software development process. GitHub Actions is a powerful CI/CD platform that automatically tests, builds, and deploys code changes. In this article, I will examine how to create comprehensive CI/CD pipelines for .NET and Flutter projects with GitHub Actions, automated deployment strategies, and production-ready workflow configurations in detail.

Since I switched to GitHub Actions in my projects, I have reduced manual deployment errors to zero. Automatic testing, building, and deployment processes on every push have both increased my development speed and improved production environment reliability.

GitHub Actions Fundamentals

Workflow Structure

GitHub Actions workflows are defined as YAML files in the .github/workflows/ directory. Each workflow contains one or more jobs, and each job consists of a series of steps.

Key concepts:

  • Workflow: Automated process definition (YAML file)
  • Event: The event that triggers the workflow (push, pull_request, schedule)
  • Job: Work units that can run in parallel or sequentially
  • Step: A single operation step within a job
  • Action: Reusable work unit (from marketplace or custom)

.NET CI/CD Pipeline

Build, Test, and Deployment

yaml
# .github/workflows/dotnet-ci-cd.yml
name: .NET CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  DOTNET_VERSION: '8.0.x'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET SDK
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

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

      - name: Restore
        run: dotnet restore

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

      - name: Run unit tests
        run: dotnet test --no-build -c Release --filter "Category!=Integration" --logger "trx;LogFileName=unit-tests.trx" --collect:"XPlat Code Coverage"

      - name: Run integration tests
        run: dotnet test --no-build -c Release --filter "Category=Integration" --logger "trx;LogFileName=integration-tests.trx"
        env:
          ConnectionStrings__DefaultConnection: "Host=localhost;Database=testdb;Username=postgres;Password=postgres"
          Redis__ConnectionString: "localhost:6379"

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

      - name: Code coverage report
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  docker-build:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Login to container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: docker-build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/myapp
            docker compose pull
            docker compose up -d --remove-orphans
            docker image prune -f

Flutter CI/CD Pipeline

Flutter Build and Test

yaml
# .github/workflows/flutter-ci-cd.yml
name: Flutter CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  analyze-and-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter SDK
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
          channel: stable
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Code analysis
        run: flutter analyze --fatal-infos

      - name: Run tests
        run: flutter test --coverage

      - name: Coverage report
        uses: codecov/codecov-action@v4
        with:
          file: coverage/lcov.info

  build-android:
    needs: analyze-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Setup Flutter SDK
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
          channel: stable
          cache: true

      - name: Decode keystore
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/keystore.jks

      - name: Build Android APK
        run: flutter build apk --release
        env:
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}

      - name: Upload APK artifact
        uses: actions/upload-artifact@v4
        with:
          name: android-release
          path: build/app/outputs/flutter-apk/app-release.apk

  build-ios:
    needs: analyze-and-test
    runs-on: macos-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter SDK
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
          channel: stable
          cache: true

      - name: Build iOS
        run: flutter build ios --release --no-codesign

      - name: Upload IPA artifact
        uses: actions/upload-artifact@v4
        with:
          name: ios-release
          path: build/ios/iphoneos/Runner.app

Advanced Workflow Techniques

Secrets Management and Security

A multi-layered approach should be adopted for secure management of sensitive data in GitHub Actions:

  • Repository secrets: General secrets available to all workflows (API keys, tokens)
  • Environment secrets: Environment-specific secret management for production and staging. Environment-based approval requirements can be added
  • OIDC: OpenID Connect for secretless connection to cloud providers like AWS or Azure. Uses short-lived tokens instead of long-lived credentials
yaml
# OIDC example for secretless AWS access
permissions:
  id-token: write
  contents: read

steps:
  - name: AWS credentials (OIDC)
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789:role/github-actions-role
      aws-region: eu-central-1

  - name: Login to ECR
    uses: aws-actions/amazon-ecr-login@v2

By using OIDC, you can completely eliminate static access keys. This method is considered the best security practice, especially for AWS and Azure integrations.

Environment-Specific Deployments

Using different configurations for different environments increases deployment safety:

yaml
# Separate deployment jobs for staging and production
  deploy-staging:
    needs: docker-build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'
    environment: staging
    steps:
      - name: Deploy to staging
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/myapp-staging
            docker compose pull
            docker compose up -d --remove-orphans

  deploy-production:
    needs: docker-build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production  # Requires manual approval
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/myapp
            docker compose pull
            docker compose up -d --remove-orphans
            docker image prune -f

Reusable Workflows

Convert repetitive workflow parts into shareable workflows. This is highly effective for enforcing standard CI/CD practices across an organization:

yaml
# .github/workflows/reusable-dotnet-build.yml
name: Reusable .NET Build

on:
  workflow_call:
    inputs:
      dotnet-version:
        required: true
        type: string
      project-path:
        required: true
        type: string
    secrets:
      CODECOV_TOKEN:
        required: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ inputs.dotnet-version }}
      - run: dotnet restore ${{ inputs.project-path }}
      - run: dotnet build ${{ inputs.project-path }} --no-restore -c Release
      - run: dotnet test ${{ inputs.project-path }} --no-build -c Release

Matrix Strategy

Use matrix strategy to run parallel tests across multiple versions or platforms:

yaml
# Matrix strategy for multi-platform testing
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        dotnet-version: ['8.0.x', '9.0.x']
      fail-fast: false

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ matrix.dotnet-version }}
      - run: dotnet test -c Release

Caching Strategies

Use effective caching strategies to reduce build times:

yaml
# Docker layer cache
- name: Docker build with cache
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

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

# Flutter pub cache
- uses: actions/cache@v4
  with:
    path: |
      ~/.pub-cache
      .dart_tool
    key: flutter-${{ hashFiles('**/pubspec.lock') }}

Monitoring cache hit rates and optimizing your cache key strategy can reduce build times by up to 50%. Caching makes a significant difference especially in projects with large dependencies.

Practical Tips and Summary

Keep these points in mind when building CI/CD pipelines with GitHub Actions:

  1. Use caching: Cache NuGet, npm, and Flutter packages to reduce build times
  2. Parallel jobs: Run independent operations in parallel to reduce total duration
  3. Environment protection: Add manual approval for production deployments
  4. Artifact management: Store build outputs as artifacts for rollback readiness
  5. Notifications: Track pipeline status with Slack or Teams integration

A well-configured CI/CD pipeline is the backbone of the software development process. Automatic testing and deployment both reduce error rates and increase delivery speed.

Related Articles

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch