Jenkins Versus GitHub Actions After Our 43-Minute Queue

jenkins

Jenkins Versus GitHub Actions After Our 43-Minute Queue

What we kept, what we moved, and why Jenkins still runs one awkward job.

At 10:17 on Tuesday, our release branch had 19 builds waiting behind a Jenkins controller that was technically healthy and practically useless. The oldest job had been queued for 43 minutes. Two engineers were watching the same green progress bar on Slack. One of them suggested “just adding executors,” which is how Jenkins makes sure we remember it has a sense of humour.

We compared three paths for the services we build and deploy from GitHub: keep Jenkins on Kubernetes, move normal CI work to GitHub Actions, or run GitLab CI for the teams already using GitLab’s package and security features. This wasn’t a feature checklist exercise. We had 34 repositories, roughly 280 builds on a busy weekday, Java and Node services, Terraform plans, and one antique deployment job that talks to a vendor appliance through a jump host.

Our plain answer: GitHub Actions won for most application CI. Jenkins stayed for the strange jobs with network access, custom hardware, or years of scripted behaviour nobody wants to translate on a Thursday afternoon. GitLab CI is a decent choice if GitLab already owns your source control. We would not introduce it beside GitHub merely to replace Jenkins.

The Queue Problem Is Really a Worker Problem

Our Jenkins controller ran as a StatefulSet in EKS, with ephemeral Kubernetes agents created through the Jenkins Kubernetes plugin. On paper, this gave us elastic executors. In practice, the agent pod templates had grown into a 480-line shared library, node images were pulled too often, and a Docker-in-Docker sidecar occasionally sat in ContainerCreating while its sibling job waited.

The controller itself wasn’t the bottleneck. The queue was.

We had capped the ci-build node group at 12 m6i.large instances because it already cost enough during normal hours. Each Maven build requested 2 CPU and 4 GiB RAM. A few test suites asked for 6 GiB, Kubernetes packed them poorly, and cluster autoscaler took four to seven minutes to add nodes. By the time capacity appeared, another branch push had arrived.

GitHub Actions moved that waiting time somewhere else. Hosted runners started most Node and unit-test jobs in under a minute. The Windows jobs were less charming, but we only have four of those. GitLab’s shared runners behave similarly, though we have seen queue time vary more with GitLab.com’s shared capacity than we like for release work.

Here’s the blunt trade-off:

| Criterion | Jenkins on Kubernetes | GitHub Actions | GitLab CI |
|—|—|—|—|
| Normal app build startup | 2–8 minutes in our cluster | 15–60 seconds | 30–120 seconds on shared runners |
| Custom network access | Excellent | Requires self-hosted runner | Requires self-managed runner |
| Pipeline code | Powerful, often overgrown Groovy | YAML plus actions | YAML, generally cleaner than Jenkinsfiles |
| Plugin and action risk | Plugin upgrades can break controllers | Third-party actions need pinning | Templates and includes need review |
| Cost shape | EKS nodes, storage, admin time | Per-minute after included use | Per-minute or runner infrastructure |
| Debugging odd failures | Logs spread across controller, pod, cluster | Usually one run page | Usually one pipeline page |
| Our recommendation | Keep for exceptional jobs | Default for GitHub-hosted teams | Default only for GitLab-hosted teams |

The surprise was that cost got worse before it got better. In the first month after moving 22 repositories, our GitHub Actions bill rose from about $180 in trial usage to $1,460. We had copied Jenkins behaviour too literally: every pull request ran integration tests, image builds, dependency scans, and a 19-minute Playwright suite.

Then we split checks by path, cancelled superseded runs, and put browser tests behind a merge-queue label. The next month landed at $690. Jenkins node spend also dropped by roughly $510, though our finance report made that connection about as obvious as a failed Helm rollback.

The Same Maven Build In Three Pipeline Files

We used the same service for the comparison: billing-api, a Spring Boot service with Maven tests, a container image, and a Helm deployment to staging. It has enough moving parts to expose weak spots, but it isn’t one of the cursed repositories.

Jenkins still gives us the most freedom. It also gives every team enough rope to build a second deployment system inside a shared library.

pipeline {
  agent {
    kubernetes {
      yaml """
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: maven
    image: maven:3.9.9-eclipse-temurin-21
    command: ['cat']
    tty: true
"""
    }
  }

  stages {
    stage('Test') {
      steps {
        container('maven') {
          sh 'mvn -B -DskipITs verify'
        }
      }
    }
    stage('Image') {
      when { branch 'main' }
      steps {
        sh './ci/build-and-push.sh'
      }
    }
  }
}

The GitHub Actions version is shorter, and more of its behaviour is visible to a developer who has never had Jenkins administrator access. We pin third-party actions to commit SHAs in the real files; abbreviated tags below are easier to read but not what we permit in protected branches. GitHub’s own security hardening guidance is sensible here, particularly on action pinning and token permissions.

name: billing-api

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - run: mvn -B -DskipITs verify

  image:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - run: ./ci/build-and-push.sh

GitLab CI is the least surprising YAML of the three for a conventional pipeline. Its rules syntax is better than the conditional knots we’ve seen in Jenkinsfile Groovy. GitLab’s runner executor documentation is worth reading before committing to shared versus Kubernetes runners; the operational split matters more than the syntax.

stages:
  - test
  - image

test:
  image: maven:3.9.9-eclipse-temurin-21
  stage: test
  cache:
    paths:
      - .m2/repository
  script:
    - mvn -B -DskipITs verify

image:
  stage: image
  image: docker:27
  services:
    - docker:27-dind
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  script:
    - ./ci/build-and-push.sh

For a plain service, we’d choose Actions. Jenkins’ extra power only pays for itself when the job really needs it, not when someone wants an input step and a decorative Build With Parameters page.

Credentials Are Where Jenkins Shows Its Age

We spent more time on credentials than migration scripts.

Jenkins had 67 credentials in its store when we started. Twelve were still in use. Seven had names like prod-token-final2, which conveyed neither ownership nor comfort. A folder-scoped credential can be perfectly reasonable, but a Jenkins administrator can still reach a great deal of it, and plugin-specific credential bindings make audits tedious.

For GitHub Actions, we switched AWS deployments to OIDC and removed long-lived access keys from the pipeline path. The trust policy limits the role to our platform/billing-api repository and the main branch environment.

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub":
        "repo:acme-platform/billing-api:environment:production"
    }
  }
}

That was one of the few changes that felt immediately cleaner. The deploy job requests a short-lived token, AWS records the role session, and we stop pretending a secret rotated every 90 days is a happy secret. GitHub documents the OIDC flow for AWS well enough that we didn’t have to invent a wrapper.

GitLab supports the same general pattern with ID tokens. If your repositories live there, use them. We would avoid storing cloud keys in either CI product unless the target system has no federation option.

Jenkins can do OIDC too, via scripts, plugins, or an external secrets operator. We have it working for two jobs. The issue is consistency: every Jenkins library can choose its own route, and then the security review turns into archaeology.

We left one Jenkins credential in place: a client certificate required by that vendor appliance. Its API only accepts mutual TLS from a fixed private address, and its certificate rotation procedure arrives as a PDF once a year. Some systems do make their own case for retirement.

Could we move that appliance job to a self-hosted GitHub runner and shut Jenkins off entirely?

Self-Hosted Runners Recreate Part of the Jenkins Problem

Yes, we could. We tested it.

A self-hosted Actions runner in the private subnet could reach the appliance, mount the certificate from Secrets Manager, and execute the deployment script. The workflow itself was fine. The trouble began when we considered who would patch, drain, scale, and investigate those runners at 02:00.

Jenkins already had that machinery, imperfectly. Our agents were Kubernetes pods, isolated per build, and constrained with service accounts and network policies. A self-hosted runner can be ephemeral too, and GitHub recommends ephemeral runners for security-sensitive work. But we would still need an autoscaler, images, AMI or container patching, labels, runner registration, and enough logging to answer “what ran on this host?”

That’s Jenkins-shaped work wearing a GitHub badge.

We tried Actions Runner Controller for a week on a non-production cluster. It worked, but it added another controller, runner scale sets, GitHub App credentials, and operational ownership. Our platform team has five people, one of whom is on parental leave until October. We don’t have spare enthusiasm for another control plane.

This only held because the appliance job runs two or three times a week. If it ran 200 times a day, we would build the runner pool and accept the maintenance. At that volume, manual exceptions become their own incident category.

For now, Jenkins owns the private-network and hardware-adjacent jobs: appliance deploys, Android signing, and a load-test suite that pushes 25 Gbit/s through a lab VLAN. There are nine of them. Everything else is moving out.

Failed Builds Need Fewer Places To Look

Jenkins failures trained our team to open three tabs: build log, Kubernetes events, and Grafana. Four tabs if the shared library had changed. A red Jenkins job might mean a test failed, an agent could not schedule, the container registry throttled us, or the controller had lost a WebSocket connection to an agent.

GitHub Actions collapses most normal failures into one place. Logs are still noisy, especially when an action emits coloured furniture instead of useful output, but the run page has the checkout, cache, test, artifact, and deployment evidence together. GitLab offers a similar experience.

There’s a cost to that convenience. Hosted runners hide the host. When a GitHub ubuntu-24.04 image changed and our Playwright browser install broke, we had less ability to inspect the runner than we had with our own pods. Pinning the browser package fixed it, but the first failure message was just:

Error: browserType.launch: Executable doesn't exist

Our Jenkins setup had a different failure mode: the browser image was pinned for so long that it contained a six-month-old Chromium build. Predictability can quietly turn into neglect.

We also found that GitHub’s concurrency control stopped a lot of wasted work:

concurrency:
  group: pr-${{ github.event.pull_request.number }}
  cancel-in-progress: true

A developer pushing five commits in ten minutes no longer buys five copies of the same integration run. Jenkins can do this with milestone steps, abort plugins, or custom Groovy. We had all three patterns across repositories, naturally.

Marta, our Tuesday primary, preferred Jenkins logs because “at least the disaster is familiar.” She was right for the first fortnight. By week three, she stopped asking which agent pod had vanished.

Plugin Freedom Has a Maintenance Bill

Our Jenkins controller had 46 installed plugins. We removed 11 before the migration and still found dependencies on workflow APIs nobody had consciously selected. A plugin update is rarely exciting until it breaks a shared library and 34 repositories lose the ability to publish test reports.

We held Jenkins core at 2.452.3 for longer than we should have because an upgrade changed behaviour in the Kubernetes plugin. That wasn’t irresponsible exactly; it was a calculated refusal to spend a Friday proving that 120 pipelines still behaved. The calculation gets less defensible each quarter.

GitHub Actions has its own supply-chain problem. Marketplace actions are code from strangers with a reassuring logo. We allow approved actions, pin them by SHA, and use reusable workflows for container publishing and deployment. Teams can read those workflows, but they cannot casually add curl | bash to production release paths.

GitLab’s includes and templates offer a similar central-control model. We prefer it over Jenkins shared libraries because YAML is less capable of becoming a small programming language with opinions about classpaths. Some people love Jenkins Groovy. We have enough Java already.

There remains one unresolved argument in our team: whether reusable GitHub workflows are becoming shared libraries with worse local testing. They probably are. We’ve agreed to keep them thin—authentication, artifact naming, policy checks—and leave build commands in each repository. Ask us again after the next 20 migrations.

Pick This If Your Team Has These Constraints

Pick GitHub Actions if you have 3–30 engineers, repositories already hosted on GitHub, and mostly standard builds: tests, containers, Terraform plans, and cloud deployments. Start on hosted runners. Use OIDC for cloud access, protect environments, cancel duplicate pull-request runs, and resist adding self-hosted runners for one awkward service.

Pick Jenkins on Kubernetes if you have 15–80 engineers and a meaningful set of jobs that require private network paths, internal build hardware, custom operating systems, or long-established scripted release steps. Keep the controller small, make agents ephemeral, and assign ownership for every shared library. Do not keep Jenkins merely because the team knows where the blue button is.

Pick GitLab CI if source code, merge requests, container registry, and security scanning already live in GitLab. For a 10–50 person team, one product for those workflows is a sensible operational trade. We would choose GitLab CI over Jenkins in that setup, but not over GitHub Actions if moving source control is the hidden price.

For our five-person platform team, the split is 25 repositories on GitHub Actions, nine Jenkins jobs left behind, and a calendar reminder in November to see whether that vendor appliance has finally learned what an API is.

Like this:

  • Aoi Kobayashi
  • Yuki Takahashi
  • Jie Wang
  • Ella Mortensen
  • Elizabeth Madsen
  • Ethan Christensen
  • Alexander Pedersen
  • Isabella Olsen
  • Elijah Ivanov
  • Ava Costa
  • Oliver Silva
  • Liam Murphy
  • Debra Roberts
  • George Hall
  • Brian Nelson
  • Nicole Adams
  • Kevin Green
  • Rebecca Moore
  • Ashley Davis
  • Amina Njoroge
32 people like this.
Share