Skip to main content
CI/CD·9 min read

GitHub Actions CI/CD — From 20-Minute Builds to Production in Under 5

Build a production-grade CI/CD pipeline with GitHub Actions. Covers caching, matrix builds, Docker, security scanning, and deployment to AWS ECS.

DT

DevOps Engineer & Technical Writer

Why GitHub Actions Wins

CI/CD PIPELINE FLOW — GITHUB ACTIONS TRIGGER Push STEP 1 Build STEP 2 Test STEP 3 Scan STEP 4 Staging GATE Approve STEP 5 Prod Lint → Type-check → Unit Tests → Security Scan → Docker Build → Deploy CACHING npm + Docker layers AUTH OIDC (no keys) PROTECTION Environment rules

GitHub Actions is now the default CI/CD for most teams. It's free for public repos, integrates natively with your code, and the marketplace has 15,000+ reusable actions. But most pipelines I review in production are poorly structured — slow builds, no caching, security gaps, and fragile deployments.

This guide builds a pipeline the right way.

The Complete Pipeline Architecture

Here's what a production pipeline should do:

  • Lint and type-check — catch errors before wasting build time
  • Run tests — unit, integration, and critical E2E tests
  • Build Docker image — multi-stage, optimized layers
  • Security scan — vulnerabilities in code and dependencies
  • Push to registry — ECR, GHCR, or Docker Hub
  • Deploy — rolling update or blue-green
  • The Full Workflow

    name: CI/CD Pipeline
    
    

    on:

    push:

    branches: [main]

    pull_request:

    branches: [main]

    env:

    AWS_REGION: ap-south-1

    ECR_REPOSITORY: myapp

    ECS_CLUSTER: production

    ECS_SERVICE: api-service

    permissions:

    contents: read

    id-token: write

    jobs:

    lint-and-test:

    runs-on: ubuntu-latest

    steps:

    - uses: actions/checkout@v4

    - uses: actions/setup-node@v4

    with:

    node-version: '20'

    cache: 'npm'

    - run: npm ci

    - name: Lint

    run: npm run lint

    - name: Type check

    run: npm run type-check

    - name: Unit tests

    run: npm run test -- --coverage

    - name: Upload coverage

    uses: actions/upload-artifact@v4

    with:

    name: coverage

    path: coverage/

    security-scan:

    runs-on: ubuntu-latest

    steps:

    - uses: actions/checkout@v4

    - name: Run Trivy vulnerability scanner

    uses: aquasecurity/trivy-action@master

    with:

    scan-type: 'fs'

    scan-ref: '.'

    severity: 'HIGH,CRITICAL'

    exit-code: '1'

    build-and-push:

    needs: [lint-and-test, security-scan]

    if: github.ref == 'refs/heads/main'

    runs-on: ubuntu-latest

    outputs:

    image-tag: ${{ steps.meta.outputs.tags }}

    steps:

    - uses: actions/checkout@v4

    - name: Configure AWS credentials

    uses: aws-actions/configure-aws-credentials@v4

    with:

    role-to-assume: ${{ secrets.AWS_ROLE_ARN }}

    aws-region: ${{ env.AWS_REGION }}

    - name: Login to ECR

    id: login-ecr

    uses: aws-actions/amazon-ecr-login@v2

    - name: Docker meta

    id: meta

    uses: docker/metadata-action@v5

    with:

    images: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}

    tags: |

    type=sha,prefix=

    type=raw,value=latest

    - name: Set up Docker Buildx

    uses: docker/setup-buildx-action@v3

    - name: Build and push

    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: build-and-push

    if: github.ref == 'refs/heads/main'

    runs-on: ubuntu-latest

    environment: production

    steps:

    - uses: actions/checkout@v4

    - name: Configure AWS credentials

    uses: aws-actions/configure-aws-credentials@v4

    with:

    role-to-assume: ${{ secrets.AWS_ROLE_ARN }}

    aws-region: ${{ env.AWS_REGION }}

    - name: Deploy to ECS

    run: |

    aws ecs update-service \

    --cluster ${{ env.ECS_CLUSTER }} \

    --service ${{ env.ECS_SERVICE }} \

    --force-new-deployment

    Key Techniques Explained

    Docker Layer Caching with GitHub Actions Cache

    The cache-from: type=gha line is critical. Without it, every build downloads all layers from scratch. With it, unchanged layers are reused from GitHub's cache.

    This typically cuts build time from 5-8 minutes down to 1-2 minutes.

    OIDC Authentication (No Access Keys)

    Notice there's no AWS_ACCESS_KEY_ID in secrets. We use OIDC:

    role-to-assume: ${{ secrets.AWS_ROLE_ARN }}

    This is more secure — no long-lived credentials. The workflow gets a temporary token that expires after the run.

    Set up OIDC in AWS:

    # Terraform to create the OIDC provider and role
    

    resource "aws_iam_openid_connect_provider" "github" {

    url = "https://token.actions.githubusercontent.com"

    client_id_list = ["sts.amazonaws.com"]

    thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]

    }

    resource "aws_iam_role" "github_actions" {

    name = "github-actions-deploy"

    assume_role_policy = jsonencode({

    Version = "2012-10-17"

    Statement = [{

    Action = "sts:AssumeRoleWithWebIdentity"

    Effect = "Allow"

    Principal = {

    Federated = aws_iam_openid_connect_provider.github.arn

    }

    Condition = {

    StringEquals = {

    "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"

    }

    StringLike = {

    "token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:*"

    }

    }

    }]

    })

    }

    Environment Protection Rules

    The environment: production on the deploy job enables:

    • Required reviewers before deploy
    • Wait timers (e.g., 5 minutes between staging and prod)
    • Branch restrictions (only main can deploy)

    Configure this in GitHub repo settings → Environments.

    Optimized Dockerfile

    Your pipeline is only as fast as your Docker build:

    # Stage 1: Dependencies (cached unless package.json changes)
    

    FROM node:20-alpine AS deps

    WORKDIR /app

    COPY package.json package-lock.json ./

    RUN npm ci --only=production

    # Stage 2: Build

    FROM node:20-alpine AS builder

    WORKDIR /app

    COPY --from=deps /app/node_modules ./node_modules

    COPY . .

    RUN npm run build

    # Stage 3: Production image (minimal)

    FROM node:20-alpine AS runner

    WORKDIR /app

    RUN addgroup -S appgroup && adduser -S appuser -G appgroup

    COPY --from=builder --chown=appuser:appgroup /app/dist ./dist

    COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules

    USER appuser

    EXPOSE 3000

    CMD ["node", "dist/server.js"]

    This produces a ~150MB image instead of 1.2GB. The multi-stage build means your production image doesn't have dev dependencies, source code, or build tools.

    Matrix Builds for Multiple Environments

    Test against multiple Node.js versions and OS:

    strategy:
    

    matrix:

    node-version: [18, 20, 22]

    os: [ubuntu-latest, macos-latest]

    fail-fast: false

    runs-on: ${{ matrix.os }}

    steps:

    - uses: actions/setup-node@v4

    with:

    node-version: ${{ matrix.node-version }}

    Preventing Bad Deploys

    Add a smoke test after deployment:

    - name: Smoke test
    

    run: |

    sleep 30

    STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://api.myapp.com/health)

    if [ "$STATUS" != "200" ]; then

    echo "Smoke test failed! Rolling back..."

    aws ecs update-service \

    --cluster $ECS_CLUSTER \

    --service $ECS_SERVICE \

    --task-definition $PREVIOUS_TASK_DEF

    exit 1

    fi

    Key Takeaways

    • Cache everything: npm dependencies, Docker layers, test results
    • Use OIDC for AWS — never store access keys as secrets
    • Run security scans before building images
    • Use multi-stage Docker builds for small, secure images
    • Add environment protection rules for production deploys
    • Always include a smoke test after deployment

    A well-structured pipeline should run in under 5 minutes for most applications. If yours takes longer, the bottleneck is almost always missing caches.

    ---

    Frequently Asked Questions

    How do I trigger a GitHub Actions workflow?

    Workflows trigger on events defined in the on: section. Common triggers include push (on commits), pull_request (on PR activity), schedule (cron-based), workflow_dispatch (manual), and repository_dispatch (API-triggered). You can filter by branches, paths, and tags to control when workflows run.

    What is the difference between jobs and steps in GitHub Actions?

    Jobs run in parallel by default on separate runners (VMs), while steps run sequentially within a single job sharing the same filesystem. Use multiple jobs for independent tasks like "test" and "lint" that can run concurrently. Use steps for sequential operations that depend on each other within the same environment.

    How do I pass data between GitHub Actions jobs?

    Use outputs to pass small values between jobs with echo "key=value" >> $GITHUB_OUTPUT and reference them in dependent jobs with needs.job-name.outputs.key. For files, use actions/upload-artifact and actions/download-artifact. Set job dependencies with needs: [job-name] to ensure correct execution order.

    Why is my GitHub Actions workflow failing with permission errors?

    Check the permissions key in your workflow — GitHub now defaults to read-only permissions. Add explicit permissions like contents: write for pushing code or pull-requests: write for commenting on PRs. For GITHUB_TOKEN, ensure the repository settings allow workflow write permissions under Settings > Actions > General.

    How do I run GitHub Actions locally for testing?

    Use act (https://github.com/nektos/act) which simulates the GitHub Actions environment locally using Docker. Run act push to simulate a push event or act -j test to run a specific job. Note that some GitHub-specific features like secrets and hosted runner tools may not be fully available locally.

    ---