DevOps & Cloud
CI/CD Pipeline: Automated Testing & Deployment with GitHub Actions
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
# .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 -fFlutter CI/CD Pipeline
Flutter Build and Test
# .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.appAdvanced 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
# 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@v2By 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:
# 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 -fReusable Workflows
Convert repetitive workflow parts into shareable workflows. This is highly effective for enforcing standard CI/CD practices across an organization:
# .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 ReleaseMatrix Strategy
Use matrix strategy to run parallel tests across multiple versions or platforms:
# 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 ReleaseCaching Strategies
Use effective caching strategies to reduce build times:
# 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:
- Use caching: Cache NuGet, npm, and Flutter packages to reduce build times
- Parallel jobs: Run independent operations in parallel to reduce total duration
- Environment protection: Add manual approval for production deployments
- Artifact management: Store build outputs as artifacts for rollback readiness
- 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
.NET CI/CD: Automated Deployment with GitHub Actions and Azure DevOps
Design CI/CD pipelines for .NET projects. GitHub Actions, Azure DevOps, and deployment strategies.
AWS Cloud Infrastructure: EC2, S3, CloudFront & Lambda Guide
Learn AWS cloud services. EC2 server management, S3 file storage, CloudFront CDN, Lambda serverless functions, and VPC networking.
Docker Container Guide: Dockerfile, Compose & Multi-Stage Builds
Docker containerization guide. Writing Dockerfiles, multi-service Docker Compose, multi-stage build optimization, and production deployment.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch