Most teams graduate from “it works on my machine” to CI/CD, then stop iterating on their pipelines. The workflow file that got you started is rarely the one you want in production six months later. Over time, pipelines accumulate redundant jobs, missing caches, and fragile bash scripts that only one person understands.
If your CI pipeline takes 15 minutes on a repo that builds in 30 seconds locally, something is wrong. Here are six patterns that make GitHub Actions workflows faster, safer, and easier to maintain — drawn from real production configurations.
1. Aggressive Dependency Caching
Caching is the single highest-impact optimization for most workflows. The actions/cache action lets you persist directories between runs, and the key design determines your hit rate.
The pattern is straightforward: hash your lockfile to create a unique cache key, and provide fallback prefixes so partial matches still save time. A Go module cache, for example, can be cached in under a second and restore hundreds of dependencies:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-mod-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
go-mod-${{ runner.os }}-
For multi-language repos, cache each ecosystem separately. Don’t try to create one monolithic cache — smaller, targeted caches restore faster and have fewer eviction conflicts. The cache limit is 10 GB per repository, so use it wisely.
2. Reusable Workflows for Standardization
When you have more than three repositories with similar CI pipelines, copy-paste becomes a maintenance burden. GitHub Actions supports reusable workflows that let you define a workflow once and call it from other repos with uses.
A reusable workflow for a Go service build and test might live in a shared repository:
# .github/workflows/build-and-test.yml (in a shared repo)
name: Build and Test
on:
workflow_call:
inputs:
go-version:
required: true
type: string
run-integration:
required: false
type: boolean
default: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ inputs.go-version }}
- uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ inputs.go-version }}-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: go-${{ inputs.go-version }}-${{ runner.os }}-
- run: go test ./...
- run: go test -tags=integration ./.../integration
if: ${{ inputs.run-integration }}
Then any repo calls it with:
# .github/workflows/ci.yml (in any service repo)
jobs:
build-and-test:
uses: acme-corp/shared-workflows/.github/workflows/build-and-test.yml@v2
with:
go-version: '1.24'
run-integration: true
secrets: inherit
This pattern enforces consistency across teams. When you need to add a security scan or update the Go version, you change one file instead of twenty.
3. Concurrency Control
Without concurrency control, every push to a branch starts a new workflow run, and they all run simultaneously. On a busy repository, this burns runner minutes and creates noise. The concurrency key at the top level of a workflow fixes this:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
This groups runs by the Git ref. When you push twice to main in quick succession, the first run gets cancelled and only the latest one completes. The cancel-in-progress: true behavior is almost always what you want for branch pushes — there’s no point finishing a build for code that’s already been superseded.
For release builds or deployments where you explicitly don’t want cancellation, you can scope the concurrency group differently:
concurrency:
group: deploy-production
cancel-in-progress: false
This serializes production deployments. If two merges to main trigger deployments, the second one waits for the first to finish.
4. Environment Protection Rules
GitHub Environments let you attach protection rules to deployment targets. Instead of gating deployments with custom workflow logic, you configure rules at the repository level:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- run: ./deploy.sh staging
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment: production
steps:
- run: ./deploy.sh production
Each environment can require reviewers, wait timers, and branch restrictions independently. A production environment might require one approval and a 5-minute wait timer, while staging deploys automatically. The workflow file stays clean — the policy lives in the repository settings.
You can also attach secrets to specific environments. Database credentials for production never need to be available to the staging deployment job. This is a simple but effective blast radius reduction.
5. Artifact Passing Between Jobs
Jobs in a GitHub Actions workflow run on separate virtual machines, so they can’t share filesystem state. The actions/upload-artifact and actions/download-artifact actions bridge this gap for build outputs, test results, and reports.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: go build -o bin/service ./cmd/service
- uses: actions/upload-artifact@v4
with:
name: service-binary
path: bin/service
retention-days: 1
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: service-binary
path: bin/
- run: ./bin/service --test.integration
Keep artifacts small and set short retention periods. Every artifact consumes storage quota, and old binary artifacts pile up fast. Set retention-days: 1 for build intermediates that only need to survive between jobs within the same run.
6. Matrix Builds with Strategic Fail-Fast
Testing across multiple Go versions or operating systems is a natural fit for the strategy.matrix feature. The default fail-fast: true cancels all matrix jobs as soon as one fails — which is usually wrong for version compatibility testing. You want to see which versions break, not just that one did:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
go-version: ['1.22', '1.23', '1.24']
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- run: go test ./...
With fail-fast: false, all six combinations run to completion even if one fails. The failure matrix in the GitHub UI then shows exactly which versions and platforms have issues, which is far more actionable than a single red X.
Combine this with actions/cache keyed on the Go version, and each matrix cell benefits from its own cache without interfering with others. This makes matrix builds nearly as fast as a single-target run.
Putting It Together
These patterns compose naturally. A production-ready workflow uses caching to keep builds fast, concurrency control to avoid wasted runs, environment protection for deployment safety, reusable workflows for consistency, artifacts for cross-job communication, and strategic fail-fast for useful test signals.
None of these require third-party actions or marketplace plugins. They’re all built into GitHub Actions and work on the free tier. The improvement is cumulative — applying even three of these patterns typically cuts pipeline time by 40-60% and eliminates an entire class of deployment misconfigurations.
If you’re just getting started with GitHub Actions, begin with caching and concurrency. Those two alone will make an immediate difference. Then layer in reusable workflows as your repo count grows, and environment protection rules when you need deployment governance.