How CI/CD Pipelines Work

From Pull Request → Test → Build → Deploy



Modern Trunk-Based Development

Learning Objectives

  • Understand what CI/CD pipelines are and why they matter
  • Explain trunk-based development and PR workflow
  • Understand each stage: PR → Test → Build → Merge → Deploy
  • Recognize how modern pipelines solve real-world development challenges
  • Apply trunk-based CI/CD practices to your own projects

Why This Matters

The Real-World Problem

What happens when you deploy code manually?

The Problem: Manual Deployment

Imagine this scenario:

  • You write code, test it locally, and it works
  • You manually copy files to the server
  • You forget to run tests before deploying
  • The server environment is different from your machine
  • Something breaks in production at 2 AM
  • You spend hours debugging what went wrong

This is the problem CI/CD pipelines solve.

Before vs After CI/CD

Without CI/CD With CI/CD
Manual deployments take hours Automated deployments in minutes
Tests run inconsistently Every commit automatically tested
"Works on my machine" problems Consistent environments everywhere
Deployments fail most of the time Deployments are successful most of the time
Rollbacks are manual and risky Automatic rollbacks on failure

What is CI/CD?

CI = Continuous Integration

Automatically test and integrate code changes frequently

CD = Continuous Deployment/Delivery

Automatically deploy code that passes all tests

Together: A pipeline that automatically builds, tests, and deploys your code

Modern Development: Trunk-Based

Trunk-Based Development Principles:

  • Single Source of Truth: One main branch (main/master)
  • No Direct Pushes: All changes go through Pull Requests
  • Short-Lived Branches: Feature branches merged quickly
  • No Staging/Dev Branches: Main branch is always deployable
  • CI on PR, CD on Merge: Test before merge, deploy after merge

Key Point: Main branch is always production-ready. No separate staging environments.

The CI/CD Pipeline Flow

From Pull Request to Production

The Complete Journey

PR Created
Test
Build
Merge
Deploy

CI runs on PR, CD runs when PR is merged to main

Stage 1: Pull Request

The Trigger

What Happens When You Create a PR?

Developer creates a Pull Request

  • You create a feature branch: git checkout -b feature/new-login
  • You commit and push: git push origin feature/new-login
  • You open a Pull Request to main
  • The CI pipeline automatically triggers
  • Pipeline runs tests and builds Docker image (but doesn't push to registry yet)

💡 Quick Check-in:

Why do you think we test BEFORE merging to main?

We catch issues early and keep main branch always deployable!

PR Workflow: Visual Example

# Developer workflow (trunk-based)
$ git checkout -b feature/fix-auth
$ git add .
$ git commit -m "Fix user authentication bug"
$ git push origin feature/fix-auth

# Create Pull Request on GitHub/GitLab
→ PR targets main branch
→ CI pipeline automatically triggers
→ Tests run, Docker images build (frontend & backend, but not pushed)
→ If all pass, PR can be merged
→ After merge to main: CD pipeline builds, pushes to ACR, and deploys to Azure Container Apps

Key Point: No direct pushes to main. Everything goes through PRs for review and automated testing.

Stage 2: Test

Quality Assurance

What Happens in Test?

Automated Testing Runs

  • Unit Tests: Test individual functions and components
  • Integration Tests: Test how components work together
  • Linting: Check code style and catch errors
  • Security Scans: Find vulnerabilities automatically

If tests pass: Pipeline continues to Build

If tests fail: Pipeline stops, developer gets notified

Test Stage: Real Example

# GitHub Actions workflow - runs on PRs
name: CI Pipeline

on:
  pull_request:
    branches: [main]  # Only on PRs targeting main

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install dependencies
        run: npm install
      
      - name: Run unit tests
        run: npm test
      
      - name: Run linting
        run: npm run lint
      
      - name: Security scan
        run: npm audit

Why this matters: Catch bugs before they merge to main. Main branch stays clean!

Stage 3: Build

Creating the Artifact

What Happens in Build?

Code is Compiled/Packaged

  • On PR: Build Docker images (frontend & backend) to verify they work (but don't push)
  • On Merge: Build Docker images and push to Azure Container Registry (ACR)
  • Compile: Transform source code into executable (if needed)
  • Bundle: Package all files together
  • Optimize: Minify, compress, optimize assets

Key Point: Build on PR to verify, but only push images to ACR when code is merged to main

We don't want Docker images from branches that might never be merged!

Build Stage: Real Example

# Build job on PR - only builds, doesn't push
jobs:
  build:
    needs: test  # Only runs if tests pass
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Build frontend image (verify it works)
        run: docker build -t frontend-app:${{ github.sha }} ./frontend
      
      - name: Build backend image (verify it works)
        run: docker build -t backend-app:${{ github.sha }} ./backend
      
      # No push on PR - we don't want images from unmerged branches

Key Point: Build on PR to verify it works, but don't push images until code is merged to main!

Stage 4: Deploy

Going Live

What Happens in Deploy?

Deployment Triggers on Merge to Main

  • PR Merged: Code is merged to main branch
  • CD Pipeline Starts: Deployment pipeline automatically triggers
  • Images Pushed: Docker images pushed to Azure Container Registry (ACR)
  • Deploy to Azure Container Apps: Frontend and backend apps deployed to production
  • Health Checks: Verify both apps are running correctly
  • Rollback: Automatically revert if something fails

💡 Think About This:

Why do we only deploy when code is merged to main?

Main branch is the source of truth. If it's in main, it's tested and ready for production!

Deploy Stage: Real Example

# Deploy job - only runs when PR is merged to main
on:
  push:
    branches: [main]  # Only on merge to main

jobs:
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      
      - name: Deploy frontend to Azure Container Apps
        uses: azure/aci-deploy@v1
        with:
          resource-group: my-resource-group
          container-app-name: frontend-app
          image: ${{ secrets.ACR_ADDR }}/frontend-app:${{ github.sha }}
      
      - name: Deploy backend to Azure Container Apps
        uses: azure/aci-deploy@v1
        with:
          resource-group: my-resource-group
          container-app-name: backend-app
          image: ${{ secrets.ACR_ADDR }}/backend-app:${{ github.sha }}
      
      - name: Health check
        run: |
          sleep 10
          curl -f ${{ secrets.FRONTEND_URL }} || exit 1
          curl -f ${{ secrets.BACKEND_URL }}|| exit 1

Safety First: Automatic health checks and rollbacks protect production. Main branch = production!

Putting It All Together

A Complete Pipeline Example

The Complete Flow

1. PR Created
2. Test
3. Build
4. Merge
5. Deploy
  • PR Created: Developer opens PR → CI pipeline triggers
  • Test: Run all tests on PR → If pass, PR can be merged; if fail, fix required
  • Build: Build Docker images (frontend & backend) on PR → Verify they work (but don't push to ACR)
  • Merge: PR approved and merged to main → CD pipeline triggers
  • Build & Push: Build Docker images and push to Azure Container Registry (only merged code)
  • Deploy: Deploy frontend-app and backend-app to Azure Container Apps → Health check → Done!

Complete Pipeline: GitHub Actions

# CI Pipeline - runs on Pull Requests
name: CI Pipeline

on:
  pull_request:
    branches: [main]

jobs:
  # Test on PR
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install
      - run: npm test
      - run: npm run lint

  # Build on PR (verify it works, but don't push)
  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build frontend image (verify)
        run: docker build -t frontend-app:${{ github.sha }} ./frontend
      - name: Build backend image (verify)
        run: docker build -t backend-app:${{ github.sha }} ./backend
      # No push - we don't want images from unmerged branches

---
# CD Pipeline - runs when PR is merged to main
name: CD Pipeline

on:
  push:
    branches: [main]

jobs:
  # Build and push images to Azure Container Registry (only on merge)
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Login to Azure Container Registry
        uses: azure/docker-login@v1
        with:
          login-server: ${{ secrets.ACR_ADDR }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}
      
      - name: Build and push frontend image
        run: |
          docker build -t ${{ secrets.ACR_ADDR }}/frontend-app:${{ github.sha }} ./frontend
          docker tag ${{ secrets.ACR_ADDR }}/frontend-app:${{ github.sha }} ${{ secrets.ACR_ADDR }}/frontend-app:latest
          docker push ${{ secrets.ACR_ADDR }}/frontend-app:${{ github.sha }}
          docker push ${{ secrets.ACR_ADDR }}/frontend-app:latest
      
      - name: Build and push backend image
        run: |
          docker build -t ${{ secrets.ACR_ADDR }}/backend-app:${{ github.sha }} ./backend
          docker tag ${{ secrets.ACR_ADDR }}/backend-app:${{ github.sha }} ${{ secrets.ACR_ADDR }}/backend-app:latest
          docker push ${{ secrets.ACR_ADDR }}/backend-app:${{ github.sha }}
          docker push ${{ secrets.ACR_ADDR }}/backend-app:latest

  # Deploy to Azure Container Apps
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      
      - name: Deploy frontend to Azure Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          acrName: ${{ secrets.ACR_NAME }}
          containerAppName: frontend-app
          resourceGroup: ${{ secrets.AZURE_RG }}
          imageToDeploy: ${{ secrets.ACR_ADDR }}/frontend-app:${{ github.sha }}
      
      - name: Deploy backend to Azure Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          acrName: ${{ secrets.ACR_NAME }}
          containerAppName: backend-app
          resourceGroup: ${{ secrets.AZURE_RG }}
          imageToDeploy: ${{ secrets.ACR_ADDR }}/backend-app:${{ github.sha }}
      
      - name: Health check
        run: |
          sleep 15
          curl -f ${{ secrets.FRONTEND_URL }} || exit 1
          curl -f ${{ secrets.BACKEND_URL }} || exit 1

Key Point: CI runs on PRs (test and verify build), CD runs on merge (build, push to ACR, and deploy to Azure Container Apps). Only merged code gets pushed to registry!

Real-World Impact

What Trunk-Based CI/CD Enables:

  • Faster Releases: Deploy multiple times per day, directly from main
  • Fewer Bugs: Catch issues in PRs before they merge to main
  • Confidence: Main branch is always tested and deployable
  • Simplicity: One branch (main), no staging/dev complexity
  • Team Productivity: Developers focus on code, not branch management
  • Code Review: PRs ensure quality before code reaches main

💬 Discussion Point:

Why do you think trunk-based development (one main branch) is better than having staging/dev branches?

Less complexity, faster feedback, and main is always production-ready!

Key Takeaways

What You've Learned

Remember the Flow

PR
Test
Build
Merge
Deploy

Core Principles:

  • No direct pushes to main - everything through PRs
  • CI runs on PRs (test before merge)
  • CD runs on merge (deploy after merge)
  • Main branch is always production-ready
  • Trunk-based: one main branch, no staging/dev branches
  • Failures stop the pipeline automatically

Next Steps

How to Apply This:

  • Adopt trunk-based development: one main branch, no staging/dev
  • Set up branch protection: require PRs, no direct pushes to main
  • Start with CI on PRs (testing and building)
  • Add CD on merge (deployment to production)
  • Use GitHub Actions, GitLab CI, or Jenkins
  • Practice on personal projects first

Remember: Modern CI/CD = PR-based workflow + trunk-based development. Keep it simple!