Most teams of up to 20 developers don't need Jenkins, TeamCity or even GitLab CI with their own runners. GitHub Actions with a well-structured pipeline covers 95% of scenarios and needs minimal upkeep. This is the CI/CD template we set up for clients in two days: four stages, aggressive caching, secrets without static keys and deploys gated by approval. Below is the full workflow, the reasoning behind every block and the signals that tell you it's time to move beyond GitHub-hosted runners.
Why GitHub Actions is enough for a small team
For a team that already hosts code on GitHub, Actions removes a whole category of work: there is no CI server to patch, no plugins to upgrade and no separate user management. Pull requests, checks, environments and deployment history live in one place.
The economics are also hard to beat at this size. Public repositories run on GitHub-hosted runners for free, and private repositories get a monthly allowance of included minutes on every plan (GitHub docs: Actions billing). From January 1, 2026, GitHub cut hosted-runner prices by up to 39%; a standard Linux 2-core runner now costs $0.006 per minute (GitHub, 2026 pricing changes; runner pricing reference). A team of 15 that runs 3,000 eight-minute builds a month spends about $144 on compute — less than one hour of engineering time spent babysitting a Jenkins server.
Speed matters as much as cost. The DORA research program has shown for a decade that short lead times and frequent deployments correlate with better organizational performance, not worse stability (DORA: software delivery performance metrics). A fast pipeline is how a small team gets there.
A baseline 4-stage pipeline
Every stage has one job and fails as early as possible:
- Lint + type-check — 30–60 seconds, fail-fast. Nothing else starts until this passes.
- Unit tests with dependency caching, split across a matrix if the suite is large.
- Build + integration tests against real services with Testcontainers or end-to-end tests with Playwright.
- Deploy to staging automatically on
main, to production through a manual approval.
Here is the core of the workflow we start from:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint && pnpm typecheck
test:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test --shard=${{ matrix.shard }}/3
build:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
Three details in this file save the most pain. concurrency cancels outdated runs on the same pull request (concurrency docs). permissions: contents: read shrinks the default GITHUB_TOKEN to the minimum (token permissions). And timeout-minutes stops a hung job from burning the default six-hour limit.
Caching — the biggest win
Without an npm, pnpm or Gradle cache your pipeline runs 3–5x slower, because every job downloads the internet again. The setup actions for Node.js, Java and Python have built-in caching keyed on the lockfile; for anything else use actions/cache with a key derived from hashFiles('**/pnpm-lock.yaml') or its equivalent.
Docker builds benefit even more. Docker Buildx can store layer cache in the GitHub Actions cache (type=gha) or in your container registry (type=registry). On our client projects that cut image builds from about 8 minutes to 90 seconds, because only the layers after the changed COPY step are rebuilt.
Two rules keep the cache useful:
- Order your Dockerfile from least to most volatile. Copy the lockfile and install dependencies before copying source code.
- Watch the cache size. Each repository gets 10 GB of Actions cache by default and older entries are evicted first (dependency caching reference). A
mode=maxDocker cache for many branches fills that quickly.
Secret hygiene
A CI system holds the keys to production, which makes it an attractive target. In March 2025 the popular tj-actions/changed-files action was compromised and leaked secrets from workflow logs in thousands of repositories (CISA alert, CVE-2025-30066). Our baseline:
- OIDC instead of static cloud keys. GitHub issues a short-lived token per job, and AWS, GCP or Azure exchange it for temporary credentials. No long-lived access key ever lives in repository secrets (OpenID Connect in GitHub Actions; AWS guide).
- Environment protection rules. Production secrets live in a
productionenvironment that onlymaincan use and that requires a reviewer's approval (managing environments). - Pin third-party actions to a full commit SHA, not a mutable tag, and let Dependabot propose updates (security hardening guide).
- Dependabot with auto-merge for minor bumps once checks pass, using
dependabot/fetch-metadata(automating Dependabot with Actions). - SAST via CodeQL. Code scanning is free for public repositories; private repositories need the GitHub Code Security add-on (About code scanning with CodeQL).
The OIDC deploy job looks like this:
deploy-production:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # requires approval, main-only
permissions:
id-token: write # allow the job to request an OIDC token
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: eu-central-1
- run: ./scripts/deploy.sh
What saves the most time
- Matrix builds. Run tests in parallel across shards or across Node.js and Python versions with
strategy.matrix(matrix docs). - Concurrency controls. Don't spend minutes on stale pull request commits; cancel them as shown above.
- Reusable workflows. Keep one
workflow_callpipeline in a shared repository and call it from every service instead of copying steps (reusing workflows). - Path filters. Don't run the frontend build when only
backend/changed, usingon.push.paths(workflow syntax).
A reusable workflow call is three lines per repository:
jobs:
ci:
uses: acme/platform-workflows/.github/workflows/node-ci.yml@v3
with:
node-version: 22
secrets: inherit
A team that waits 25 minutes on CI stops making small commits. CI slower than 8 minutes is a process problem, not a tooling problem.
How to measure your pipeline
Track a handful of numbers every month and treat regressions like bugs:
| Metric | Target for a team under 20 | Where to find it |
|---|---|---|
| Median PR pipeline duration | under 8 minutes | Actions usage metrics |
| Flaky test rate | under 2% of runs | Test reports, re-run counts |
| Deployment frequency | daily or more | Deployment history per environment |
| Change failure rate | under 15% | Incidents linked to deploys |
| Cost per month | tracked, not guessed | Billing → Usage |
The last three are DORA metrics; the DORA guide explains how to measure them without heavy tooling. GitHub's own Actions usage and performance metrics show which workflows and jobs eat the most minutes.
When GitHub Actions stops working
GitHub-hosted runners stop being the obvious choice in three situations:
- Cost. Your team grows past 50 developers and you pay $3,000+ per month for Actions minutes.
- Hardware. You need GPUs, more memory, ARM builds at scale or access to a private network.
- Duration. Large monorepos or Android builds regularly exceed 20–30 minutes even with caching.
At that point move to self-hosted runners on Hetzner or your own Kubernetes cluster with Actions Runner Controller, which scales ephemeral runners on demand. We usually see 5–10x savings, and setup takes 2–3 days. GitHub announced a $0.002 per-minute platform charge for self-hosted runners in December 2025 and then postponed it (GitHub, 2026 pricing changes), so keep an eye on that announcement in your cost model. For the cluster side, see Kubernetes on Hetzner: 5x cheaper than AWS — myth or reality.
Related guides
Your pipeline is part of the software supply chain. We go deeper into SBOMs, signing and provenance in Container and supply chain security, map pipeline risks to the OWASP Top 10:2025, and cover infrastructure pipelines in Terraform vs OpenTofu.
Pipelines can now host AI reviewers and coding agents too; see AI code review and AI agents in CI/CD.
FAQ
GitHub Actions or GitLab CI for a small team? If your code is already on GitHub, Actions. Both are capable; the integration with pull requests, environments and Dependabot is what saves time.
How many free minutes do we get? It depends on the plan; private repositories get a monthly allowance and public repositories run on standard runners for free (Actions billing).
Is it safe to deploy to production from GitHub Actions?
Yes, with OIDC, a protected production environment, required reviewers and actions pinned to commit SHAs.
What about monorepos? Combine path filters with reusable workflows per package, or adopt a build tool with remote caching such as Turborepo or Nx.
Sources
- GitHub (2025). Pricing changes for GitHub Actions.
- GitHub Docs. Actions runner pricing and GitHub Actions billing.
- GitHub Docs. OpenID Connect.
- GitHub Docs. Managing environments for deployment.
- GitHub Docs. Secure use reference.
- GitHub Docs. Reusing workflows.
- Docker Docs. Cache management with GitHub Actions.
- CISA (2025). Supply chain compromise of tj-actions/changed-files (CVE-2025-30066).
- DORA. DORA's software delivery metrics: the four keys.