CI/CD With GitHub Actions — A Practical, End‑to‑End Tutorial



This content originally appeared on DEV Community and was authored by Goodluck Ekeoma Adiole

What Is CI/CD?

  • Continuous Integration (CI): automate build, lint, test, and packaging on every change.
  • Continuous Delivery/Deployment (CD): automate promotion to environments (staging → production), often with approvals, feature flags, and rollbacks.
  • Benefits: faster feedback, fewer regressions, reproducible releases, higher confidence.

CI/CD Tools at a Glance (Quick Compare)

  • Jenkins: highly extensible, self‑hosted; you manage controllers/agents and plugins.
  • GitLab CI: tightly integrated with GitLab; YAML pipelines, built‑in container registry.
  • Azure Pipelines: great for Microsoft stacks; Windows/macOS/Linux hosted pools.
  • CircleCI: cloud‑hosted, fast parallelism, orbs ecosystem.
  • Bitbucket Pipelines: simple pipelines for Bitbucket repos.
  • Buildkite: hybrid model; run builders on your infra.
  • Tekton: Kubernetes‑native pipelines (CRDs).
  • Argo CD / Flux: GitOps CD for Kubernetes (pull‑based).
  • Spinnaker / Harness: powerful multi‑cloud CD, canary/blue‑green baked in.

Why GitHub Actions? Native to GitHub, enormous marketplace, generous hosted runners, great DX, reusable workflows, and first‑class security integrations (OIDC, environments, approvals).

GitHub Actions Core Concepts

  • Workflow: YAML in .github/workflows/*.yml
  • Trigger (Event): push, pull_request, workflow_dispatch, schedule, release, etc.
  • Job: runs on a runner (runs-on: ubuntu-latest, windows-latest, etc.). Jobs can depend on others via needs.
  • Step: individual shell command or “action” (like actions/checkout).
  • Runners: GitHub‑hosted or self‑hosted (ephemeral or static).
  • Artifacts & Caching: persist build outputs; speed up installs.
  • Environments: dev, staging, prod with protection rules, approvals, and secrets.
  • Secrets & Variables: org/repo/environment scope; injected at runtime.
  • Permissions: least‑privilege via permissions: (and OIDC via id-token: write).

A Minimal CI Workflow (Node example)

Create .github/workflows/ci.yml:

name: CI

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Use Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install
        run: npm ci

      - name: Lint
        run: npm run lint --if-present

      - name: Test
        run: npm test -- --ci --reporters=default --reporters=jest-junit

Notes:

  • Runs on PRs and on pushes to main.
  • Caches node_modules automatically via setup-node@v4 + cache: npm.

Python & Java Variants

Python (.github/workflows/python-ci.yml):

name: Python CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.10, 3.11, 3.12]
      fail-fast: false
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
      - run: pip install -r requirements.txt
      - run: pytest -q --maxfail=1 --disable-warnings --junitxml=report.xml
      - uses: actions/upload-artifact@v4
        with:
          name: pytest-report
          path: report.xml

Java (Gradle):

name: Java CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21
          cache: gradle
      - run: ./gradlew build --stacktrace
      - uses: actions/upload-artifact@v4
        with:
          name: app-jar
          path: build/libs/*.jar

Service Containers for Integration Tests

Example: test against Postgres and Redis

name: Integration Tests
on: [push, pull_request]

jobs:
  itest:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: password
          POSTGRES_DB: appdb
        ports: ["5432:5432"]
        options: >-
          --health-cmd="pg_isready -U app -d appdb"
          --health-interval=10s --health-timeout=5s --health-retries=5
      redis:
        image: redis:7

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: 3.11 }
      - run: pip install -r requirements.txt
      - env:
          DATABASE_URL: postgresql://app:password@localhost:5432/appdb
          REDIS_URL: redis://localhost:6379
        run: pytest tests/integration -q

Caching & Artifacts (Speed and Traceability)

  • Use setup actions’ built‑in caching (cache: npm/pip/gradle).
  • For custom caches:
- name: Cache build
  uses: actions/cache@v4
  with:
    path: .m2/repository
    key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      maven-${{ runner.os }}-
  • Upload build outputs for later jobs or downloads:
- uses: actions/upload-artifact@v4
  with:
    name: dist
    path: dist/**
    if-no-files-found: error

CD Basics: Environments, Approvals, and Secrets

  1. Create environments in GitHub: dev, staging, prod.
  2. Add environment secrets/variables (e.g., PROD_DB_URL).
  3. Configure protection rules (approvers, wait timers).

Sample CD job gated by an environment:

jobs:
  deploy_prod:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: prod
      url: https://app.example.com
    permissions:
      contents: read
      id-token: write  # for OIDC to cloud providers
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: ./scripts/deploy.sh

Approvers receive a prompt in the PR/Actions UI before the job runs.

Multi‑Stage CI/CD (Build → Test → Deploy)

name: Webapp CI/CD

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  id-token: write

concurrency:
  group: app-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.tags }}
    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 }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=sha
            type=semver,pattern={{version}}
      - name: Build & Push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

  test:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  deploy_staging:
    runs-on: ubuntu-latest
    needs: [test]
    environment: staging
    steps:
      - name: Deploy to Staging
        run: ./scripts/deploy_staging.sh ${{ needs.build.outputs.image }}

  deploy_prod:
    runs-on: ubuntu-latest
    needs: [deploy_staging]
    environment: prod
    steps:
      - name: Deploy to Production
        run: ./scripts/deploy_prod.sh ${{ needs.build.outputs.image }}

Cloud Deployments via OIDC (No Long‑Lived Secrets)

AWS (ECS/EKS/Lambda/etc.)

- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GHActionsDeployRole
    aws-region: eu-west-1

- name: Deploy infra with Terraform
  run: |
    terraform init
    terraform apply -auto-approve

- name: Update ECS service
  run: aws ecs update-service --cluster app --service web --force-new-deployment

Prereq: set IAM role with a trust policy allowing GitHub’s OIDC provider and your repo/environment.

Azure (Web Apps/AKS)

- uses: azure/login@v2
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Deploy to Azure Web App
  uses: azure/webapps-deploy@v3
  with:
    app-name: my-webapp
    images: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}

Use Azure Federated Credentials for your repo/environment to avoid client secret rotation.

GCP (Cloud Run/GKE)

- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: projects/123/locations/global/workloadIdentityPools/gh/providers/github
    service_account: gha-deployer@myproj.iam.gserviceaccount.com

- name: Deploy to Cloud Run
  run: |
    gcloud run deploy web --image=ghcr.io/${{ github.repository }}:${{ github.sha }} --region=europe-west1

Terraform in Actions (Infra as Code)

name: Terraform

on:
  pull_request:
    paths: ["infra/**.tf", ".github/workflows/terraform.yml"]
  push:
    branches: [main]
    paths: ["infra/**.tf"]

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform -chdir=infra init
      - run: terraform -chdir=infra plan -out=plan.out
      - uses: actions/upload-artifact@v4
        with:
          name: tf-plan
          path: infra/plan.out

  apply:
    if: github.ref == 'refs/heads/main'
    needs: plan
    environment: prod
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - uses: actions/download-artifact@v4
        with: { name: tf-plan, path: infra }
      - run: terraform -chdir=infra apply -auto-approve plan.out

Secure Supply Chain (SCA, SAST, Code Scanning)

  • Dependency Review on PRs:
name: Dependency Review
on: [pull_request]
permissions:
  contents: read
  pull-requests: write
jobs:
  dep-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/dependency-review-action@v4
  • CodeQL (static analysis):
name: CodeQL
on:
  push: { branches: [main] }
  pull_request:
  schedule: [{ cron: "35 1 * * 1" }] # weekly
permissions:
  contents: read
  security-events: write
jobs:
  analyze:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix: { language: [javascript, python, java] }
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with: { languages: ${{ matrix.language }} }
      - uses: github/codeql-action/analyze@v3
  • Optional: container provenance (SLSA‑style):
- name: Attest build provenance
  uses: actions/attest-build-provenance@v1
  with:
    subject-name: ghcr.io/${{ github.repository }}
    subject-digest: ${{ steps.meta.outputs.digest }}

Reusable Workflows & Composite Actions

Reusable workflow (publisher):

.github/workflows/reusable-test.yml

name: Reusable Test
on:
  workflow_call:
    inputs:
      node-version: { required: true, type: string }
jobs:
  run-tests:
    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 && npm test

Caller:

jobs:
  tests:
    uses: your-org/your-repo/.github/workflows/reusable-test.yml@main
    with:
      node-version: "20"

Composite action (encapsulate repeatable steps):

.github/actions/setup-app/action.yml

name: "Setup App"
runs:
  using: "composite"
  steps:
    - uses: actions/setup-node@v4
      with: { node-version: 20, cache: npm }
    - run: npm ci
      shell: bash

Use it:

- uses: ./.github/actions/setup-app

Monorepos & Path Filters

Run pipelines only when relevant code changes:

on:
  pull_request:
    paths:
      - "services/api/**"
      - ".github/workflows/api-*.yml"

jobs:
  api-ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make -C services/api test

Matrix by service:

strategy:
  matrix:
    service: [api, web, worker]
steps:
  - run: make -C services/${{ matrix.service }} test

Branching & Release Strategies

  • Trunk‑based (recommended): PRs → main; feature flags; short‑lived branches.
  • GitFlow: develop, release/*, hotfix/*; more ceremony.
  • Semver tags trigger releases:
on:
  push:
    tags: ["v*.*.*"]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build Changelog
        run: npx conventional-changelog -p angular -i CHANGELOG.md -s
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: |
            dist/**

Advanced Controls & Guardrails

  • concurrency: to prevent overlapping deploys.
  • environment: with required reviewers and wait timers.
  • timeout-minutes: per job.
  • if: conditions (e.g., only deploy on tags).
  • needs: to enforce stage order.
  • permissions: minimal scopes; add id-token: write only when needed.
  • Pin actions to major versions or SHAs (for maximum supply‑chain safety).
  • Secret scanning & push protection: keep secrets out of code.

Self‑Hosted Runners (When and How)

Why: custom tools, private networks, GPUs, cost control, long builds.

Basic setup steps:

  1. Provision VM/container with network access to targets.
  2. Create runner in repo/org (Settings → Actions → Runners).
  3. Label runners (e.g., self-hosted, gpu, arm64).
  4. Prefer ephemeral/auto‑scaled runners (clean state per job).

Use in workflow:

jobs:
  build:
    runs-on: [self-hosted, linux, x64, docker]

Security tips:

  • Lock runners to specific repos.
  • Rotate tokens; auto‑update runner.
  • Isolate with VM snapshots or ephemeral images.

Slide 18 — Observability, Test Reports, Coverage, and Artifacts

  • Upload test reports & coverage to keep PR feedback rich.
  • Publish HTML reports as artifacts or Pages in non‑prod.
  • Example (Jest coverage):
- run: npm run test -- --coverage
- uses: actions/upload-artifact@v4
  with:
    name: coverage
    path: coverage/**
  • Annotate PRs via problem matchers or actions (eslint, flake8, etc.).

Scheduling, Manual Runs, and Branch Protections

  • Schedules use UTC (not repository timezone):
on:
  schedule:
    - cron: "0 5 * * 1-5" # 05:00 UTC on weekdays
  workflow_dispatch:
    inputs:
      target:
        description: "Env to deploy"
        required: true
        default: "staging"
  • Combine with branch protection rules (required checks before merge).

End‑to‑End Example: Dockerized API → Staging/Prod (ECS)

.github/workflows/api-cicd.yml:

name: API CI/CD

on:
  pull_request:
    paths: ["api/**", ".github/workflows/api-cicd.yml"]
  push:
    branches: [main]
    paths: ["api/**", ".github/workflows/api-cicd.yml"]

permissions:
  contents: read
  id-token: write

env:
  IMAGE_NAME: ghcr.io/${{ github.repository }}/api

jobs:
  ci:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.version }}
    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 }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=sha,format=long
            type=ref,event=branch

      - name: Build & Push
        uses: docker/build-push-action@v6
        with:
          context: ./api
          push: true
          tags: ${{ steps.meta.outputs.tags }}

      - uses: actions/upload-artifact@v4
        with:
          name: image-tags
          path: |
            # emit a simple file with the final tag(s)
            /dev/stdout
        if: always()

  deploy_staging:
    needs: ci
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gh-ecs-deployer
          aws-region: eu-west-1
      - name: Update ECS service (staging)
        run: |
          aws ecs update-service \
            --cluster app-staging \
            --service api \
            --force-new-deployment

  deploy_prod:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: deploy_staging
    runs-on: ubuntu-latest
    environment: prod
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gh-ecs-deployer
          aws-region: eu-west-1
      - name: Update ECS service (prod)
        run: |
          aws ecs update-service \
            --cluster app-prod \
            --service api \
            --force-new-deployment

Flow:

  • PR → CI only.
  • Push to main → build/push image + deploy to staging.
  • Create tag vX.Y.Z → deploy to prod (after staging job completes and environment approval, if configured).

Common Pitfalls & Troubleshooting

  • “Permission denied” on checkout/push: set permissions: contents: write when publishing tags or creating releases.
  • OIDC failing: verify correct audience/repo/environment in cloud role trust policy.
  • Slow builds: add caches, narrow paths, enable parallel matrix, use bigger runners (e.g., ubuntu-latest vs ubuntu-24.04 as available).
  • Flaky tests: add service health checks and retries; separate unit vs integration; use timeout-minutes.
  • Secrets not found: confirm secret scope (environment vs repo vs org) and name casing.

Security Best Practices Checklist

  • Least‑privilege permissions: per workflow/job.
  • Use environments for prod with required reviewers.
  • Prefer OIDC to cloud over static keys.
  • Pin actions to major versions or commit SHAs.
  • Keep runners ephemeral or routinely cleaned.
  • Enable branch protections + required status checks.
  • Turn on secret scanning, Dependabot alerts & updates.
  • Store sensitive config as environment secrets, not repo secrets if env‑specific.

Suggested Project Structure

.
├─ api/                      # your app(s)
├─ infra/                    # terraform/helm
├─ scripts/                  # deploy scripts
├─ .github/
│  ├─ actions/
│  │  └─ setup-app/          # composite action
│  └─ workflows/
│     ├─ ci.yml
│     ├─ codeql.yml
│     ├─ terraform.yml
│     └─ api-cicd.yml
└─ Dockerfile

Quick Start Checklist

  1. Add .github/workflows/ci.yml and run a PR.
  2. Add environments and secrets for staging and prod.
  3. Add OIDC role/federated credentials in your cloud.
  4. Implement build → test → deploy workflow with approvals.
  5. Add CodeQL + Dependency Review.
  6. Add path filters and caches.
  7. Monitor, iterate, and keep pipelines as code.

Copy‑Paste Starters (Grab‑Bag)

Manual Deploy with Inputs

on:
  workflow_dispatch:
    inputs:
      env:
        type: choice
        options: [staging, prod]
        default: staging

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.env }}
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy_${{ inputs.env }}.sh

Blue‑Green (Feature Flag‑Style) Toggle

- name: Flip production traffic
  run: ./scripts/switch_traffic.sh --to blue

Conditional Job (Only on PRs from internal repo)

if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}

What to Implement Next

  • Tracing CI duration and break‑down per step; set goals for speed.
  • Parallelize test suites via matrix/shards.
  • Add canary/percentage rollouts (ECS, Cloud Run, AKS/GKE).
  • GitOps for Kubernetes (Argo CD/Flux) and make Actions only push manifests/images.
  • DORA metrics (deployment frequency, lead time, MTTR, change‑fail rate) from Actions runs.


This content originally appeared on DEV Community and was authored by Goodluck Ekeoma Adiole