In short
- GitHub Actions is the default when code lives on GitHub: 2,000 free private-repo minutes a month, free public repos, OIDC to AWS.
- Use OIDC federation instead of stored cloud keys. Every tool here supports it; Jenkins needs the OpenID Connect Provider plugin.
- Self-host runners or agents for private networks and heavy builds. Add Argo CD when you deploy to Kubernetes.
On this page
A small team of roughly two to twenty engineers must pick where builds, tests and deploys run. Compared here: GitHub Actions, GitLab CI/CD, Jenkins, CircleCI and Buildkite, plus Argo CD for GitOps deploys to Kubernetes. You get the current free limits, how each handles self-hosted runners, secrets and keyless access to your cloud, caching, a complete sample pipeline, and a decision table at the end.
I use GitHub Actions, GitLab CI/CD and Jenkins, alongside Terraform, Docker, Kubernetes and Amazon ECS. The opinions below come from that work; the numbers come from each vendor’s own pages.
Which one to pick
- Code on GitHub: use GitHub Actions. It is already there, the free allowance covers most small teams, and OIDC to AWS means no stored cloud keys.
- Code on GitLab: use GitLab CI/CD. Register your own runner early, because the free hosted allowance is small.
- Builds that must run inside a private network: self-hosted runners on GitHub or GitLab first, Buildkite if you want a hosted control plane with agents you own, Jenkins if you have the people to run it.
- Heavy parallel test suites: CircleCI’s free concurrency is generous.
- Kubernetes deploys: keep your CI for build and test, and let Argo CD pull changes into the cluster.
Free tiers and limits
Checked on 11 November 2026. All figures are for the vendors’ hosted (SaaS) offerings.
| Tool | Free allowance | Users on free | Self-hosted runners | First paid step |
|---|---|---|---|---|
| GitHub Actions | 2,000 minutes a month for private repos; standard runners free for public repos; 500 MB artifact storage | Unlimited collaborators | Free | Team, $4 per user per month, 3,000 minutes |
| GitLab CI/CD (GitLab.com) | 400 compute minutes a month; 10 GiB storage | 5 per private top-level group | Quota covers GitLab instance runners only | Premium, $29 per user per month billed annually, 10,000 minutes |
| Jenkins | Free, open source | No licence limit | Everything is self-hosted | None; you pay for servers |
| CircleCI | 30,000 credits a month, about 3,000 minutes on a medium Docker executor | Up to 5 active users | 5 concurrent runner tasks | Performance, from $15 a month |
| Buildkite | Up to 2,000 hosted Linux minutes a month (small shape) | Up to 5 | Up to 10 concurrent jobs | Pro, $30 per active user per month |
| Argo CD | Free, open source | No licence limit | Runs in your cluster | None |
Some detail behind the table.
GitHub now expresses the included allowance as money rather than minutes: $12 of compute a month on GitHub Free, which is 2,000 minutes on a standard Linux 2-core runner at $0.006 a minute. Other runners burn it faster: Windows 2-core is $0.010 and macOS is $0.062 a minute. In December 2025 GitHub announced a $0.002 a minute platform charge for self-hosted runners from March 2026, then postponed it shortly after “to take time to re-evaluate our approach”. The billing docs still list self-hosted runner usage as free.
GitLab multiplies minutes by a cost factor: a small Linux runner counts 1, medium 2, large 3, and macOS M1 counts 6. The 400 minutes go quickly on anything bigger than small.
CircleCI charges in credits. A medium Docker executor (2 CPU, 4 GB) costs 10 credits a minute. Free also includes 30 concurrent Linux jobs, 1 macOS job, 2 GB storage and 1 GB network.
Buildkite keeps builds and artifacts for 30 days on the free plan, with 50 GB of artifact storage a month.
Jenkins current releases support Java 21 and Java 25. If you have an old controller on Java 17, plan that upgrade first.
Hosted versus self-hosted runners
Hosted runners are clean machines you do not maintain. Self-hosted runners are your machines running the vendor’s agent. You need them when jobs must reach private resources (a database, an internal registry, a VPC endpoint), need hardware the vendor does not offer, or run so often that hosted minutes cost more than a server.
How each tool approaches it:
- GitHub Actions: self-hosted runners are free to use; you pay for the machines. GitHub’s security guide says they “should almost never be used for public repositories”, because anyone can open a pull request that runs code on your machine. Prefer ephemeral runners that take one job and are destroyed.
- GitLab CI/CD: register a runner against a project or group. The compute-minute quota applies to GitLab’s instance runners, so your own runners are a common way to live within 400 minutes.
- Jenkins: everything is self-hosted, including the controller. The Jenkins security docs are blunt: do not run builds on the built-in node, because they get “the same level of access to the controller file system as the Jenkins process”. Set its executors to 0 and use agents.
- CircleCI: hosted by default, with self-hosted runners for private networks (5 concurrent tasks on free).
- Buildkite: built around your own agents from the start. Buildkite runs the web UI and scheduling; your agents run the code and hold the source, secrets and artifacts. This is its main difference from the others.
The trade is the same everywhere: a self-hosted runner is a server you patch, monitor and isolate. A long-lived runner that builds untrusted code is a foothold into your network.
Secrets and OIDC to your cloud
The most important security decision in CI/CD is not which tool, but whether your pipeline holds long-lived cloud keys. It should not.
All five CI tools can issue a short-lived, signed OpenID Connect (OIDC) token to each job. Your cloud account trusts the CI provider as an identity provider, checks claims in the token (which repository, which branch), and returns temporary credentials for one role. Nothing in CI can leak that is still valid next week.
| Tool | Where secrets live | OIDC token |
|---|---|---|
| GitHub Actions | Repository, organisation and environment secrets | Job requests it with permissions: id-token: write; issuer https://token.actions.githubusercontent.com |
| GitLab CI/CD | CI/CD variables: Visible, Masked or Masked and hidden; optionally Protected | id_tokens: keyword in the job, with an aud value |
| Jenkins | Credentials store | OpenID Connect Provider plugin; issuer typically https://YOUR_JENKINS_HOST/oidc |
| CircleCI | Contexts, restricted by security group, project or expression | $CIRCLE_OIDC_TOKEN and $CIRCLE_OIDC_TOKEN_V2; issuer https://oidc.circleci.com/org/<organization_id> |
| Buildkite | Buildkite secrets, scoped to a cluster, read with buildkite-agent secret get |
buildkite-agent oidc request-token --audience sts.amazonaws.com; issuer https://agent.buildkite.com |
A few rules that apply whichever tool you pick:
- Scope the trust policy tightly. Allow one repository and one branch (or one environment) to assume the deploy role, not the whole organisation.
- Remember masking is cosmetic. GitLab’s docs say outright that masking “is not a guaranteed way to prevent malicious users from accessing variable values”. Anyone who can change the pipeline can print a secret.
- Protect production secrets. GitLab’s Protected flag limits a variable to protected branches and tags. On GitHub, environment secrets in private repositories need Pro, Team or Enterprise, and required reviewers on Free, Pro and Team plans only work in public repositories.
- Give the job token the least permission. On GitHub, set
permissions:explicitly at workflow level.
The AWS trust policy for GitHub Actions
Create an IAM OIDC identity provider for https://token.actions.githubusercontent.com with audience sts.amazonaws.com, then a role with this trust policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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:acme/shop-web:ref:refs/heads/main"
}
}
}
]
}
Two details catch people out:
- If the job uses
environment:, the subject changes torepo:acme/shop-web:environment:production, and a branch-based condition no longer matches. - Repositories created after 15 July 2026 use ID-based subjects by default, such as
repo:acme@123456/shop-web@456789:ref:refs/heads/main. Older repositories keep the name format unless they opt in. Check which one your repository uses before writing the policy.gh api repos/acme/shop-web --jq '.owner.id, .id'prints both IDs.
The same idea in GitLab
GitLab’s documented pattern requests a token and exchanges it with the AWS CLI:
deploy:
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
script:
- >
aws_sts_output=$(aws sts assume-role-with-web-identity
--role-arn ${ROLE_ARN}
--role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
--web-identity-token ${GITLAB_OIDC_TOKEN}
--duration-seconds 3600
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text)
- export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $aws_sts_output)
- aws sts get-caller-identity
The aud value must match the audience configured on the AWS identity provider.
Caching
Most pipeline time on small projects goes to installing dependencies. Cache them, keyed on the lockfile.
| Tool | How | Limits worth knowing |
|---|---|---|
| GitHub Actions | actions/cache, or cache: npm in actions/setup-node |
10 GB per repository included; entries unused for 7 days are removed; a branch can restore its own caches and the default branch’s, not a sibling’s |
| GitLab CI/CD | cache: with key: files: and paths: |
Stored on the runner unless distributed cache (S3) is configured |
| Jenkins | Job Cacher plugin’s cache step, or persistent agents |
Stored on the controller by default; S3 supported |
| CircleCI | restore_cache and save_cache with {{ checksum "package-lock.json" }} |
Kept up to 15 days |
| Buildkite | Your agents’ disks | As durable as your agents |
CircleCI’s documented pattern:
- restore_cache:
keys:
- deps-{{ checksum "package-lock.json" }}
- deps-
- run: npm ci
- save_cache:
key: deps-{{ checksum "package-lock.json" }}
paths:
- ~/.npm
Cache the package manager’s download cache (~/.npm), not node_modules, when you use npm ci, because npm ci deletes node_modules before installing.
A sample pipeline: GitHub Actions with OIDC deploy to AWS
This workflow tests every pull request, and on main builds a static front end and deploys it to S3 and CloudFront with no AWS keys stored in GitHub. The build is uploaded once and reused, so what you tested is what you ship.
name: ci
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- uses: actions/upload-artifact@v7
with:
name: site
path: dist/
retention-days: 3
deploy:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/download-artifact@v8
with:
name: site
path: dist/
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy-shop-web
aws-region: eu-west-2
- run: aws s3 sync dist/ s3://shop-web-production --delete
- run: >
aws cloudfront create-invalidation
--distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
--paths "/*"
What each choice does:
permissions: contents: readat the top keeps every job’s token read-only. Only the deploy job getsid-token: write.- The workflow-level
concurrencycancels superseded runs on the same branch; the deploy job’s own group stops two deploys overlapping and never cancels one halfway. - The deploy job has no
environment:, so its OIDC subject isrepo:acme/shop-web:ref:refs/heads/main, which matches the trust policy above. - The distribution ID is a variable, not a secret, because it is not sensitive.
GitHub’s security guidance recommends pinning third-party actions to a full commit SHA rather than a tag. I have used major tags here for readability; in a real repository, pin SHAs and let Dependabot or Renovate update them.
Add security scanning to the same workflow once the basics work. See SAST tools compared and dependency scanning tools compared.
Argo CD for GitOps deploys
The pipeline above pushes changes out. For Kubernetes, GitOps reverses it: Argo CD runs inside the cluster, watches a Git repository of manifests, and makes the cluster match. Your CI builds and pushes the image, then commits the new image tag to the manifests repository. CI never needs cluster credentials.
Install it (the getting started guide uses the stable manifests; pin a release tag in production):
kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts \
-f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
argocd admin initial-password -n argocd
--server-side is required because some Argo CD CRDs exceed the annotation size limit of client-side apply. Change the admin password, then delete the argocd-initial-admin-secret secret.
Declare an application:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: shop-api
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/deploy-config.git
targetRevision: main
path: apps/shop-api/production
destination:
server: https://kubernetes.default.svc
namespace: shop
syncPolicy:
automated:
prune: true
selfHeal: true
prune deletes resources removed from Git; selfHeal reverts manual changes made with kubectl. Argo CD polls the repository every 120 seconds plus up to 60 seconds of jitter by default, so a change lands within about three minutes.
Argo CD is worth it when you run several services on Kubernetes and want drift correction and an audit trail of every change in Git. For one service on Amazon ECS or a single VM, a deploy step in CI is simpler.
Decision table
| If you… | Choose | Why |
|---|---|---|
| Host code on GitHub and build standard Linux workloads | GitHub Actions | No extra system, 2,000 free private minutes, public repos free |
| Host code on GitLab or want repo, CI and registry in one product | GitLab CI/CD | Integrated; add your own runner to stretch 400 minutes |
| Must build inside a private network with no SaaS control plane | Jenkins | Fully self-hosted; budget time for plugins, upgrades and controller security |
| Want a hosted UI but all code and secrets on your machines | Buildkite | Agents in your network; 10 concurrent self-hosted jobs free |
| Run large parallel test suites | CircleCI | 30 concurrent Linux jobs on the free plan |
| Deploy many services to Kubernetes | Argo CD plus any CI above | Pull-based deploys, drift correction, no cluster keys in CI |
| Have only public open-source repos | GitHub Actions | Standard hosted runners are free for public repositories |
If your deploy target is a static site, you may not need a cloud account at all: see hosting a website for free on Cloudflare Workers. For monitoring what you ship, see the New Relic free tier guide.
Pipeline checklist
- Pick the CI that sits next to your code unless you have a specific reason not to.
- Check the free allowance against your real build minutes, including cost factors for bigger or non-Linux runners.
- Replace every stored cloud key with OIDC, and scope trust policies to one repository and branch or environment.
- On GitHub, check whether your repository uses name-based or ID-based OIDC subjects.
- Never run self-hosted runners for public repositories; prefer ephemeral runners everywhere.
- Cache the package manager’s download cache, keyed on the lockfile.
- Set least-privilege job permissions and pin third-party actions to commit SHAs.
- Add Argo CD when Kubernetes deploys outgrow a CI deploy step.
Sources
- GitHub Actions billing docs.github.com
- GitHub pricing github.com
- Coming soon: simpler pricing and a better experience for GitHub Actions (GitHub changelog) github.blog
- Self-hosted runners (GitHub Docs) docs.github.com
- Secure use reference (GitHub Docs) docs.github.com
- OpenID Connect reference (GitHub Docs) docs.github.com
- Configuring OpenID Connect in Amazon Web Services (GitHub Docs) docs.github.com
- Deployments and environments (GitHub Docs) docs.github.com
- Dependency caching reference (GitHub Docs) docs.github.com
- GitLab pricing about.gitlab.com
- Compute minutes (GitLab Docs) docs.gitlab.com
- CI/CD variables (GitLab Docs) docs.gitlab.com
- Configure OpenID Connect in AWS to retrieve temporary credentials (GitLab Docs) docs.gitlab.com
- Caching in GitLab CI/CD docs.gitlab.com
- Java support policy (Jenkins) jenkins.io
- Controller isolation (Jenkins) jenkins.io
- OpenID Connect Provider plugin (Jenkins) plugins.jenkins.io
- Job Cacher plugin (Jenkins) plugins.jenkins.io
- CircleCI pricing circleci.com
- CircleCI price list circleci.com
- Using OpenID Connect tokens in jobs (CircleCI) circleci.com
- Using contexts (CircleCI) circleci.com
- Caching dependencies (CircleCI) circleci.com
- Buildkite pricing buildkite.com
- buildkite-agent oidc (Buildkite Docs) buildkite.com
- Buildkite secrets buildkite.com
- npm ci (npm Docs) docs.npmjs.com
- Argo CD getting started argo-cd.readthedocs.io
- Argo CD automated sync policy argo-cd.readthedocs.io
- Argo CD declarative setup argo-cd.readthedocs.io