GitHub Actions ships with every repository, runs on managed infrastructure, and integrates with every major cloud. By 2026 it has become the default CI/CD choice for teams that do not want to maintain a separate Jenkins or Buildkite cluster. But "it works" and "it works safely at production scale" are very different things. This guide covers the gap.
What changed in 2026
- OIDC is the standard for cloud auth. AWS, GCP, and Azure all support GitHub OIDC federation. Teams that still use static
AWS_ACCESS_KEY_ID secrets are taking unnecessary risk and will find their security audits flagging it.
- Actions cache v4 handles concurrent writes gracefully and is keyed on lockfile hash out of the box. Cache hit rates above 90% are routine.
- Larger runners are now cost-effective. GitHub-hosted
ubuntu-24.04-8core runners (4× CPU, 8× RAM) cut Docker build times by 3–4× versus ubuntu-latest for a modest price premium.
- Merge queues went GA. Branch protection with merge queues serializes PRs, so you stop seeing "passed CI but broke main after merge."
A minimal, correct deploy workflow
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC
contents: read
jobs:
build:
runs-on: ubuntu-24.04
outputs:
image: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build
runs-on: ubuntu-24.04
environment: staging
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy-staging
aws-region: us-east-1
- run: |
aws ecs update-service \
--cluster staging \
--service api \
--force-new-deployment
deploy-production:
needs: deploy-staging
runs-on: ubuntu-24.04
environment: production # requires reviewer approval
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy-prod
aws-region: us-east-1
- run: |
aws ecs update-service \
--cluster production \
--service api \
--force-new-deployment
OIDC authentication (no static secrets)
Create a trust policy in AWS that allows the Actions token for your repository:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub":
"repo:your-org/your-repo:environment:production"
}
}
}
The sub condition scopes the trust to a specific environment, so the production role cannot be assumed by a staging job or a PR build.
Dependency caching
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm' # hashes pnpm-lock.yaml automatically
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip' # hashes requirements.txt / pyproject.toml
For Docker layers, cache-from: type=gha (shown above) stores layer blobs in Actions cache and restores them on the next run. Combined with multi-stage builds, this cuts image build times from ~4 minutes to under 60 seconds on cache hits.
How to structure environments
| Environment |
Trigger |
Gate |
Secrets scope |
| preview |
PR open |
none (auto) |
test credentials only |
| staging |
push to main |
none (auto) |
staging credentials |
| production |
staging passes |
required reviewer |
prod credentials |
Set this up under Settings → Environments in the GitHub UI, not in workflow YAML. The environment protection rules (required reviewers, deployment branch restrictions) are enforced by GitHub, not by YAML you can accidentally delete.
How to pick the right runner
| Workload |
Runner |
| Lint, unit tests |
ubuntu-latest (2-core, free) |
| Docker build, integration tests |
ubuntu-24.04-4core |
| Large monorepo, heavy build |
ubuntu-24.04-8core |
| macOS app build |
macos-15 (billed separately) |
| GPU model inference tests |
Self-hosted with GPU |
Paying ~3× for a 4-core runner that finishes in a quarter of the time is usually cost-neutral and improves developer experience.
Common mistakes
Checking out code in every job. Only the jobs that need source code should run actions/checkout. Downstream deploy jobs just need the image tag from outputs.
Storing deploy credentials as repository secrets instead of environment secrets. Repository secrets are accessible to every job, including PR builds from forks. Environment secrets are scoped.
No rollback plan. Every deploy workflow should have a rollback workflow or a manual trigger that re-deploys the previous image tag. ECS, Kubernetes, and most PaaS platforms support this natively.
Running tests and deploy in the same job. Separate concerns. Tests run on every push; deploy runs only when tests pass on main.
What to skip
- Self-hosted runners for simple apps — managed runners are easier to maintain and the cost difference is small.
- Workflow files with 300+ lines — extract logic into composite actions or reusable workflows; long YAML is unreadable.
- Deploying to production on every PR merge without staging — even a 5-minute smoke test on staging catches the majority of regressions.
FAQ
How do I pass an image tag between jobs?
Use outputs on the build job and reference it with ${{ needs.build.outputs.image }} in downstream jobs (as shown in the example above).
Can I run the deploy workflow locally?
Use act (github.com/nektos/act) to run workflows locally. It does not perfectly replicate the GitHub environment but catches most YAML syntax and logic errors before push.
How do I handle database migrations in the pipeline?
Run migrations as a step in the staging deploy job before the new containers start. Use a migration tool (Flyway, Alembic, golang-migrate) that supports versioned, idempotent migrations and can roll back if the health check fails.
How do I limit who can trigger a production deployment?
Set required reviewers in the environment settings. Only those users can approve the pending deployment. Combine with deployment branch restrictions so only main can deploy to production.
Where to go next