GitHub Actions CI/CD - Deep Dive
Workflow architecture, reusable workflows, composite actions, runner choice, security hardening, monorepo strategies, and deploy patterns.
GitHub Actions is the most flexible CI/CD in the GitHub ecosystem, and the most foot-gunny. This is the architecture and security view.
Execution model
A workflow is YAML in .github/workflows/. Triggered by events: push, pull_request, schedule (cron), workflow_dispatch (manual), repository_dispatch (API). Each workflow run has jobs. Jobs are independent by default, run in parallel, on separate runners.
Jobs can depend on each other with needs:. Steps within a job run sequentially on the same runner, sharing the workspace and disk.
Runners
Three options:
- GitHub-hosted: free tier 2000 minutes/month for private repos, 2 vCPU 7 GB Linux, 3 vCPU 14 GB macOS, ephemeral.
- Larger GitHub-hosted: 4, 8, 16, 64 vCPU. Pay per minute. Faster builds.
- Self-hosted: your own machines, register them as runners, route specific jobs to them via labels. Cheaper for high-volume CI, more maintenance.
At Binocs we used GitHub-hosted 4-vCPU runners for most workflows, self-hosted EC2 runners for heavy integration tests that needed VPC access to dev infrastructure.
Workflow architecture
For a service repo:
.github/workflows/pr.yml: validation on PRs..github/workflows/main.yml: build + push + deploy on main..github/workflows/release.yml: tag-triggered prod deploy..github/workflows/nightly.yml: scheduled long tests, dep updates, security scans.
For a monorepo:
- One workflow per service, scoped with
paths:filter to only trigger when that service's code changes. - A shared workflow for common steps (lint, typecheck) at the root.
- Use Turborepo or Nx to determine affected packages.
on:
push:
paths:
- 'services/payments/**'
- '.github/workflows/payments.yml'Reusable workflows
Avoid copy-pasting steps across workflows. Reusable workflows live in .github/workflows/ and are called via uses:.
# .github/workflows/build-image.yml
on:
workflow_call:
inputs:
service:
type: string
required: true
secrets:
ECR_ROLE:
required: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.ECR_ROLE }}
# ... build and pushCalled from another workflow:
jobs:
build:
uses: ./.github/workflows/build-image.yml
with:
service: payments
secrets:
ECR_ROLE: ${{ secrets.ECR_ROLE }}Composite actions
If you have a sequence of steps used in multiple jobs (not just workflows), package as a composite action.
# .github/actions/setup-node-with-cache/action.yml
name: Setup Node with cache
inputs:
node-version:
default: '20'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
shell: bashUse it:
- uses: ./.github/actions/setup-node-with-cache
with:
node-version: '22'Security hardening
GitHub Actions has had high-profile compromises (supply chain attacks on popular actions). Defenses:
- Pin actions by SHA, not tag.
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.0. Tags can be moved, SHAs cannot. - Use Dependabot to update pinned actions; review the diff.
- Set minimum permissions on
GITHUB_TOKEN:permissions: contents: readby default, grant more per job. - Never
pull_request_targetwith PR head checkout. That gives any contributor code execution with secrets access. - OIDC to cloud providers instead of long-lived secrets.
- Restrict which actions can run via repo settings: allow only verified creators, or maintain an allowlist.
- Use environment protection rules: require approval before deploying to prod.
OIDC, in depth
GitHub Actions issues a signed JWT (the OIDC token) to each workflow run. The token has claims about the repo, branch, environment, workflow.
You create an IAM role in AWS (or equivalent) with a trust policy that accepts tokens from GitHub's OIDC provider, with conditions on those claims:
{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:binocs/payments:ref:refs/heads/main"
}
}
}The sub claim restricts to a specific repo and branch. Stricter alternatives: repo:binocs/payments:environment:production for environment-based scoping.
Docker builds with BuildKit
Multi-stage Dockerfile + BuildKit + registry cache = fast incremental builds.
# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]In the workflow:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
push: true
tags: ${{ env.ECR_URI }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.ECR_URI }}:buildcache
cache-to: type=registry,ref=${{ env.ECR_URI }}:buildcache,mode=maxThe mode=max cache mode pushes intermediate layers too, not just the final image. Bigger cache, faster rebuilds.
Monorepo strategy
Three approaches:
- Path filters per service. Simple, works if services are truly independent. Breaks down with shared libraries.
- Turborepo / Nx affected-packages detection. Run only what changed (and its dependents).
- Custom diff-based script: parse
git diff, decide which services to build.
We used Turborepo at one stage: turbo run build --filter=...[origin/main] builds only packages that changed since main. Massive CI time savings.
Deploy patterns
Push-based (CI deploys):
- run: aws eks update-kubeconfig --name prod
- run: helm upgrade payments ./chart --set image.tag=${{ github.sha }}Pull-based (GitOps with ArgoCD):
- run: |
cd gitops-repo
yq -i '.image.tag = "${{ github.sha }}"' apps/payments/values-prod.yaml
git commit -am "deploy payments ${{ github.sha }}"
git pushPull-based wins for: auditability (git history is the deploy log), self-healing, no cluster credentials in CI.
Common patterns
Concurrency: prevent multiple deploys to the same env at once.
concurrency:
group: deploy-${{ github.ref }}-prod
cancel-in-progress: falseEnvironments with approval:
jobs:
deploy:
environment: production
runs-on: ubuntu-latest
steps:
- run: ./deploy.shConfigure the production environment with required reviewers in repo settings.
Manual triggers with inputs:
on:
workflow_dispatch:
inputs:
version:
description: 'Version to deploy'
required: true
type: stringWhat goes wrong
- Workflow runs queued for 30 minutes during peak hours on GitHub-hosted free runners. Solution: pay for larger runners or self-host.
- Actions you depend on get updated and break your workflow. Solution: pin by SHA, test updates in a branch.
- Secrets leak via PR titles, branch names, or environment variables echoed in logs. Solution: actions runtime masks known secrets in output, but be careful.
- Cache misses because key changes too often (timestamp in key). Solution: key only on real inputs (lock file hash).
Learn more
- Docs
- Docs
- DocsReusable WorkflowsGitHub
- DocsDocker BuildKitDocker
- ArticleJulia Evans: How GitHub Actions worksJulia Evans