Start with whether to move at all
Plenty of articles assume Jenkins is something to escape. It is not always. Jenkins is mature, flexible and runs on your own hardware, which matters in some environments. If your controller is stable, your plugins are maintained and nobody dreads upgrades, a migration spends engineering time to arrive roughly where you are.
The case for moving is usually one or more of these: the controller and plugins demand constant care, plugin updates break pipelines, agents are a scaling and security burden, onboarding new engineers to Groovy pipelines is slow, or your source code already lives on GitHub and you want CI in the same place. When those costs are real, a staged move to GitHub Actions can pay back. This page sets out the path we follow in our CI/CD pipeline work, complementing the shorter walkthrough in our guide to migrating Jenkins to GitHub Actions. We also have broader pages on GitHub Actions and Jenkins as concepts.
When to stay on Jenkins
Be honest about the alternatives:
- Strict on-premises or air-gapped requirements. Hosted runners may not be allowed. Self-hosted Actions runners exist, but you then lose part of the simplicity argument.
- Deeply specialised plugins with no equivalent, for example certain hardware or legacy tool integrations.
- A stable setup that is rarely changed, where migration risk exceeds benefit.
- Source control that is not GitHub. In that case GitLab CI or your host’s native CI is a better comparison than moving to Actions.
If you are in this position, a good alternative to migrating is to reduce Jenkins’ pain: containerise agents, manage the controller as code, pin and prune plugins, and move shared logic into a tested library.
What a good migration looks like
- Driven by an inventory, not by enthusiasm.
- Staged, service by service, with Jenkins still available as the fallback.
- Improvements included. Short-lived credentials, caching, reusable workflows and parallel tests arrive as part of the move, not as a vague later phase.
- Evidence-based cutover. Old and new produce the same artefacts before the old one is switched off.
- Complete decommissioning. Jenkins is actually retired, so you stop paying for two systems.
The path, step by step
1. Inventory
Jenkins hides complexity. We list every job, Jenkinsfile and freestyle job, every plugin in use, credential, agent label, shared library function, trigger and downstream consumer, along with last-run dates. The first surprise is usually that a large fraction of jobs have not run in months. Those are archived rather than migrated.
2. Triage and design
Remaining jobs are grouped by pattern: most services turn out to be variations of three or four pipeline shapes. Each shape becomes a reusable workflow, which is where much of the long-term value comes from. We decide how to handle each awkward category (see below) and what runner strategy to use: GitHub-hosted, self-hosted, or a mix.
3. Translate intent, not syntax
Jenkins and Actions have different models. A declarative Jenkinsfile organises stages on agents, with post actions; Actions organises jobs and steps triggered by events, with marketplace actions for common tasks. A rough mapping:
| Jenkins concept | GitHub Actions equivalent |
|---|---|
pipeline, stages, stage | workflow, jobs, steps within jobs |
agent { label 'x' } | runs-on: ... (hosted or self-hosted labels) |
environment { ... } | env: at workflow, job or step level |
credentials('id') | secrets, or better, OIDC federation |
parallel { ... } | jobs running concurrently, or a matrix |
when { branch 'main' } | if: conditions and trigger filters |
post { failure { ... } } | steps or jobs with if: failure() |
input step approval | environment protection rules |
| Shared library | reusable workflows and composite actions |
An example of a straightforward translation:
pipeline {
agent { label 'docker' }
stages {
stage('Test') { steps { sh 'npm ci && npm test' } }
stage('Build') { steps { sh 'docker build -t app:$GIT_COMMIT .' } }
}
}
becomes:
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
- run: docker build -t app:${{ github.sha }} .
Notice what changed beyond syntax: the Node version is explicit, dependencies are cached, and the image is tagged by commit SHA. That is the point of translating intent. Doing this by hand for each pipeline shape is slower than blindly converting, and produces a much better result. GitHub’s Actions Importer can audit your Jenkins instance and draft workflows, which is a good way to size the effort and start the work, though the drafts always need review.
4. Run in parallel
For each service, the new workflow runs on real commits beside the Jenkins job. We compare artefacts (image digests will differ, so we compare contents, test results and exit status), durations and failures. Divergences are resolved before cutover. This is also when you get a fair before and after benchmark.
5. Cut over and decommission
Services move one at a time, starting with low-risk ones. Branch protection rules switch to the new checks, Jenkins jobs are disabled (not deleted) for a defined period, then archived. When the last job moves, the controller, agents and credentials are retired and their secrets revoked. Skipping this step is common, and it leaves a forgotten, unpatched server holding credentials.
The awkward parts
Credentials. Jenkins credential stores often hold long-lived cloud keys. The migration is a chance to replace them with OIDC federation, covered in secrets management. Do not copy the old keys into GitHub secrets.
Self-hosted agents. If agents have special hardware, tools or network access, you need self-hosted runners, ideally ephemeral and containerised. They bring patching and isolation responsibilities that GitHub-hosted runners do not.
Shared libraries. Groovy libraries carry years of accumulated logic. Port what is used, drop what is not, and expect to re-express the rest as reusable workflows and scripts.
Manual and approval steps. Jenkins input prompts map to environment approvals, which we cover under promotion gates.
Long-running or scheduled jobs. Some jobs are not CI at all, such as nightly data tasks. Actions’ scheduled workflows may suit them, or another scheduler may be better. Run-time limits and cost need checking.
Build history. You will not move old build logs. Archive what compliance requires and accept that the rest stays in Jenkins until it is retired.
Common failure modes
- Migrating everything, including dead jobs.
- Line-by-line ports that reproduce Jenkins’ problems in new YAML.
- No parallel run, so differences appear in production.
- Copying secrets instead of improving them.
- Never decommissioning Jenkins, leaving two systems to maintain.
- Underestimating self-hosted runner effort.
Delivery and handover
We deliver the inventory and recommendation first, so you can decide with data. If you proceed, the migration is delivered in waves with the shared workflow library, runner setup, credential changes and runbooks, and your team is involved in each translation so they can maintain the result. Our responsibility ends with a retired Jenkins, or a documented decision to keep part of it.
How to tell it is working
Track the number of jobs remaining on Jenkins, pipeline duration and failure rate compared with the baseline, engineering hours spent on CI upkeep, and the date the controller is switched off. If you are still weighing the decision, book a consultation and we will start with the inventory.
Where this fits
This page is the how-to for the migration itself. If you are deciding whether and when to move, see Jenkins migration without breaking releases. For a wider view of the tools, read Migrate Jenkins to GitHub Actions.