Why GitHub Actions Wins
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:
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.
---
Related Resources
- Git Cheatsheet — 106 git commands by workflow
- DORA Metrics Calculator — DORA metrics calculator
- Production Reference Architectures — GitOps CI/CD reference architecture
- GitHub Actions Cache Optimization — Speed up your CI/CD pipelines with caching
- Zero Downtime Deployment Strategies — Deployment patterns for CI/CD pipelines