Arvind Verma

CI/CD tools compared: GitHub Actions, GitLab, Jenkins and more

GitHub Actions, GitLab CI/CD, Jenkins, CircleCI, Buildkite and Argo CD compared for small teams: free limits, runners, OIDC, caching and a decision table.

By Arvind Verma10 min read

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
  1. Which one to pick
  2. Free tiers and limits
  3. Hosted versus self-hosted runners
  4. Secrets and OIDC to your cloud
  5. Caching
  6. A sample pipeline: GitHub Actions with OIDC deploy to AWS
  7. Argo CD for GitOps deploys
  8. Decision table
  9. Pipeline checklist

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:

  1. If the job uses environment:, the subject changes to repo:acme/shop-web:environment:production, and a branch-based condition no longer matches.
  2. 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: read at the top keeps every job’s token read-only. Only the deploy job gets id-token: write.
  • The workflow-level concurrency cancels 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 is repo: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

  1. GitHub Actions billing docs.github.com
  2. GitHub pricing github.com
  3. Coming soon: simpler pricing and a better experience for GitHub Actions (GitHub changelog) github.blog
  4. Self-hosted runners (GitHub Docs) docs.github.com
  5. Secure use reference (GitHub Docs) docs.github.com
  6. OpenID Connect reference (GitHub Docs) docs.github.com
  7. Configuring OpenID Connect in Amazon Web Services (GitHub Docs) docs.github.com
  8. Deployments and environments (GitHub Docs) docs.github.com
  9. Dependency caching reference (GitHub Docs) docs.github.com
  10. GitLab pricing about.gitlab.com
  11. Compute minutes (GitLab Docs) docs.gitlab.com
  12. CI/CD variables (GitLab Docs) docs.gitlab.com
  13. Configure OpenID Connect in AWS to retrieve temporary credentials (GitLab Docs) docs.gitlab.com
  14. Caching in GitLab CI/CD docs.gitlab.com
  15. Java support policy (Jenkins) jenkins.io
  16. Controller isolation (Jenkins) jenkins.io
  17. OpenID Connect Provider plugin (Jenkins) plugins.jenkins.io
  18. Job Cacher plugin (Jenkins) plugins.jenkins.io
  19. CircleCI pricing circleci.com
  20. CircleCI price list circleci.com
  21. Using OpenID Connect tokens in jobs (CircleCI) circleci.com
  22. Using contexts (CircleCI) circleci.com
  23. Caching dependencies (CircleCI) circleci.com
  24. Buildkite pricing buildkite.com
  25. buildkite-agent oidc (Buildkite Docs) buildkite.com
  26. Buildkite secrets buildkite.com
  27. npm ci (npm Docs) docs.npmjs.com
  28. Argo CD getting started argo-cd.readthedocs.io
  29. Argo CD automated sync policy argo-cd.readthedocs.io
  30. Argo CD declarative setup argo-cd.readthedocs.io