CI migrations usually get scoped as “port the pipelines.” Translate the Jenkinsfiles into .gitlab-ci.yml, stand up runners, cut over. That part is tedious but tractable. The part that bites is the credentials.

A Jenkins controller that has been running for a few years has accumulated credentials that nobody can fully account for: global ones, folder-scoped ones, secrets pasted into job configs, tokens stored by plugins, keys that “the old deploy job” needed. On a Jenkins-to-GitLab migration I’ve been working on, the temptation is always the same: export them, import them, move on. That moves the problem to a new system with a fresh coat of paint. The migration is the one moment you get to fix it, so it’s worth fixing.

Start by assuming the old credentials are exposed

Jenkins encrypts stored credentials, but the keys to decrypt them sit right next to them, under $JENKINS_HOME/secrets/. Anyone with a copy of the Jenkins home directory can recover every credential, and that includes anyone who has ever had access to a backup of it. Anyone with access to the script console can print them outright.

So the honest security posture for a long-lived Jenkins controller is that its credentials have been readable by more people and systems than you’d like to list. That makes the migration rule simple: migrate the need, not the value. Every credential gets re-issued on the way over, or replaced with something that isn’t a stored secret at all.

Inventory before you move anything

For each credential in Jenkins, answer four questions:

  1. What uses it? Which jobs, and are those jobs being migrated or retired?
  2. What does it unlock? A cloud account, a registry, an SSH host, a third-party API.
  3. Who owns that system? The person who can rotate it at the source.
  4. When was it last rotated? Often the answer is “when it was created.”

Two useful things fall out of this. First, a surprising number of credentials turn out to be unused, and those get revoked at the source and deleted, not migrated. Second, you end up with an owner for every credential that remains, which is the thing you’ll need the next time one leaks.

Replace static cloud keys with OIDC

The biggest win is cloud access. Long-lived AWS access keys stored as CI variables are the most common and most dangerous secret in pipelines. GitLab can issue each job a signed OIDC token instead, and AWS can trust that token directly:

deploy:
  stage: deploy
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: sts.amazonaws.com
  variables:
    AWS_ROLE_ARN: arn:aws:iam::ACCOUNT_ID:role/gitlab-deploy
    AWS_WEB_IDENTITY_TOKEN_FILE: /tmp/web-identity-token
  script:
    - echo "$GITLAB_OIDC_TOKEN" > "$AWS_WEB_IDENTITY_TOKEN_FILE"
    - aws sts get-caller-identity
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

With those two environment variables set, the AWS CLI and SDKs assume the role automatically. The role’s trust policy matches the token’s sub claim, which GitLab formats as project_path:group/project:ref_type:branch:ref:main. Pin it to the exact project and branch, not a wildcard. The credentials expire on their own, an hour by default, and there’s nothing to rotate, because there’s nothing stored.

One catch in locked-down environments: AWS has to fetch your GitLab instance’s public signing keys to validate the token. If the instance isn’t reachable from the internet, that direct trust won’t work, and a secrets broker inside the boundary, such as Vault’s JWT auth method, is the usual alternative. The pattern is the same either way: the job proves who it is with a short-lived token and gets short-lived credentials back.

For what has to stay a secret

Some things really are static secrets: a third-party API key, a signing certificate, a service account password for a system that doesn’t speak OIDC. For those, GitLab CI/CD variables have the controls you need, if you actually use them:

  • Protected. A protected variable is only exposed to pipelines running on protected branches and tags. Anyone who can push a feature branch can’t simply echo it.
  • Masked, and hidden where you can. Masking redacts the value from job logs. Hidden variables can’t be read back in the UI after they’re saved, which removes another quiet copy.
  • Environment-scoped. A production credential should only be available to jobs deploying to the production environment.
  • File type for certificates and kubeconfigs, so the job reads a path rather than having the value expanded into a command line where it might get logged.

Know masking’s limits. It’s a string match on job output. If a script base64-encodes the value, splits it, or URL-encodes it, the transformed version prints in the clear. And set -x in a shell script will happily trace every expanded variable. Masking is a backstop, not a reason to print secrets.

Runners are where secrets get used

Variables are only as safe as the machine that receives them. A few runner rules carry most of the weight:

  • Separate runners by trust level. Production deploys shouldn’t share runners with arbitrary merge request pipelines. Mark the deploy runners protected so they only pick up jobs from protected refs.
  • Avoid the privileged Docker executor for anything that runs untrusted code. A privileged container is effectively root on the host, and the host is where other jobs’ secrets pass through.
  • Prefer ephemeral runners. Autoscaled runners that are destroyed after a job leave nothing behind for the next job to find.
  • Treat runners as servers. In a federal environment, runners sit inside the authorization boundary, which means vulnerability scanning, STIG compliance checks, and inventory like any other host. That’s worth doing everywhere else, too.

Also lock down the job token. CI_JOB_TOKEN can access other projects unless you restrict it, so set an allowlist of exactly which projects each project’s jobs are allowed to reach.

Decommissioning is part of the migration

The Jenkins controller isn’t done when the last pipeline moves. It’s done when every credential it ever held has been revoked or rotated at the source system, and its home directory and backups have been destroyed. Deleting a credential from the Jenkins UI doesn’t do anything to the AWS key, the registry token, or the SSH key it pointed to. Those still work until someone revokes them where they live.

A CI migration is mostly translation work, and it’s tempting to keep it that way. But it’s also the cheapest chance you’ll get to cut static credentials out of your pipelines, put an owner on every secret that’s left, and make the new platform better than the one it replaced instead of just newer.