Short answers to what readers ask most about this topic.
01How do I run Claude Code in GitLab CI/CD?
Add a masked ANTHROPIC_API_KEY variable under Settings then CI/CD then Variables, and add a job that installs the CLI with the install script and calls claude with the -p flag. Set the permission mode to acceptEdits and name the tools Claude may use, including the GitLab MCP tool, in the allowedTools flag.
02Why does my GitLab job say claude: not found after installing it?
The installer places the binary in ~/.local/bin, which is not on PATH in the node alpine image. Add an export line putting that directory on PATH in your before_script, after the install step. The error names the command rather than the path, which is why it sends people back to the install step that actually worked.
03Does mentioning @claude in a GitLab comment work out of the box?
No. GitLab listens for the triggers you configure, and mention-driven runs need a piece you supply: a project webhook for comment events pointing at an event listener that calls the pipeline trigger API with the comment context as variables. If a mention appears to do nothing, the listener is usually what is missing.
04Can I use Claude Code in GitLab CI without storing an API key?
Yes, on Amazon Bedrock and Google Cloud's Agent Platform. GitLab mints an OIDC token for the job from an id_tokens block; the job exchanges it for temporary AWS credentials with assume-role-with-web-identity, or uses Workload Identity Federation for Google Cloud. No long-lived keys are stored in either path.
05How do I control Claude Code costs in a GitLab pipeline?
Three independent brakes: the --max-turns flag caps back-and-forth iterations and bounds token spend, GitLab's job-level timeout caps runner minutes which are billed separately, and concurrency limits cap how many jobs run at once. Specific prompts also cost less than vague ones, because a vague run only stops when it exhausts its turn budget.
The GitLab integration is one job and one masked variable. The parts that stall teams are the mention trigger you have to build and a PATH line in the installer.
Claude Code runs in GitLab CI/CD as an ordinary job that installs the CLI and calls it in non-interactive mode. It is a beta integration maintained by GitLab. Setup is one job in the pipeline file plus a masked variable, and it supports the Claude API, Amazon Bedrock via OIDC, and Google Cloud's Agent Platform via Workload Identity Federation.
My first GitLab job installed Claude Code successfully and then failed with claude: not found. The install had worked perfectly. The installer puts the binary in a directory that is not on the path in the node alpine image, and one missing export line turned a working setup into fifteen minutes of reading a log that said the install succeeded.
That is representative of this integration: the concept is simple and the friction is entirely in the plumbing. This post covers what the integration actually is, the minimal job, the mention-driven trigger you have to build yourself, the two keyless authentication paths for enterprise providers, and the flags that keep a runaway job from costing more than it saves.
It is the CLI in a job, not a GitLab app
There is no GitLab application to install and no service to authorise. The integration is built on the Claude Code CLI and Agent SDK: a job installs the CLI, runs it with a prompt in non-interactive mode, and lets it commit results back through a merge request. Every change flows through an MR, so reviewers see the diff and your existing approvals and branch protection still apply.
Two things follow from that. First, this is currently in beta and maintained by GitLab rather than Anthropic, with support tracked in a GitLab issue rather than through Anthropic. Second, anything you can do with the CLI you can do here — the constraints are the ones you put in the job, not ones the integration imposes.
The minimal job, and the line everybody misses
This is the whole setup: add a masked variable for your API key under the project's CI/CD settings, then add a job. Note the export line in the before-script — it is the difference between a working pipeline and a confusing one.
# .gitlab-ci.yml — the smallest job that works.
stages:
- ai
claude:
stage: ai
image: node:24-alpine3.21
rules:
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
variables:
GIT_STRATEGY: fetch
before_script:
- apk update
- apk add --no-cache git curl bash
- curl -fsSL https://claude.ai/install.sh | bash
# The installer puts claude in ~/.local/bin, which is NOT on
# PATH in this image. Miss this line and the job fails with
# "claude: not found" AFTER a successful install.
- export PATH="$HOME/.local/bin:$PATH"
script:
- /bin/gitlab-mcp-server || true
- >
claude
-p "Review this MR and implement the requested changes"
--permission-mode acceptEdits
--allowedTools "Bash Read Edit Write mcp__gitlab"
--max-turns 25
timeout: 30m
The installer places the binary in a directory that is not on the path in this image, so without that export line the job installs Claude Code successfully and then fails to find it. The error names the command rather than the path, which sends you looking at the install step that worked rather than the environment that did not. It costs an afternoon exactly once.
The mention trigger is not included
Every write-up of this integration shows someone typing an at-mention in an MR comment and Claude responding, which makes it look like comment handling is built in. It is not. GitLab listens for whatever triggers you configure, and mention-driven runs need a piece you supply: a project webhook for comment events pointing at an event listener, which calls the pipeline trigger API with the comment context as variables when it sees the mention.
The job then reads those variables — the input, the context and the event — and passes the input to the CLI as the prompt, falling back to a default when the job was started some other way. If your comment mention appears to do nothing, the listener is almost always what is missing, not the job. It is also worth checking the comment uses the at-sign form rather than a slash, which is a surprisingly common typo.
Keyless authentication for Bedrock and Agent Platform
For enterprise environments the interesting part is that neither provider needs a stored key. GitLab mints an OIDC token for the job from an id-tokens block and exposes it to the script, which exchanges it for temporary credentials at runtime. The audience value has to match what you configured on the identity provider — usually your GitLab instance URL.
# Amazon Bedrock via OIDC — no static AWS keys anywhere.
claude-bedrock:
stage: ai
image: node:24-alpine3.21
rules:
- if: '$CI_PIPELINE_SOURCE == "web"'
id_tokens:
GITLAB_OIDC_TOKEN:
# Must match the audience configured on the IAM OIDC
# identity provider in AWS — usually your GitLab URL.
aud: https://gitlab.example.com
before_script:
- apk add --no-cache bash curl jq git aws-cli
- curl -fsSL https://claude.ai/install.sh | bash
- export PATH="$HOME/.local/bin:$PATH"
- export AWS_WEB_IDENTITY_TOKEN_FILE="/tmp/oidc_token"
- printf "%s" "$GITLAB_OIDC_TOKEN" > "$AWS_WEB_IDENTITY_TOKEN_FILE"
- >
aws sts assume-role-with-web-identity
--role-arn "$AWS_ROLE_TO_ASSUME"
--role-session-name "gitlab-claude-run"
--web-identity-token "file://$AWS_WEB_IDENTITY_TOKEN_FILE"
--duration-seconds 3600 > /tmp/aws_creds.json
- export AWS_ACCESS_KEY_ID="$(jq -r .Credentials.AccessKeyId /tmp/aws_creds.json)"
- export AWS_SECRET_ACCESS_KEY="$(jq -r .Credentials.SecretAccessKey /tmp/aws_creds.json)"
- export AWS_SESSION_TOKEN="$(jq -r .Credentials.SessionToken /tmp/aws_creds.json)"
variables:
AWS_REGION: "us-west-2"
CLAUDE_CODE_USE_BEDROCK: "1"
Google Cloud's Agent Platform follows the same shape through Workload Identity Federation: the job writes the OIDC token to a file, writes a credential configuration whose credential source points at that file, and exposes it through the application default credentials variable. No service account keys are downloaded at any point. Both paths are strictly better than a long-lived secret in a CI variable, and both take about twenty minutes to set up once.
Permissions: what the job can actually do
Two separate permission systems apply here and confusing them produces the most common failure, which is a job that runs happily and then cannot comment or open an MR:
GitLab API access comes from the job token by default, or from a project access token with API scope stored as a masked variable if the job token is not enough for your project.
Claude's own tool access comes from the allowed-tools flag, and the GitLab MCP tool has to be named there or Claude cannot touch GitLab at all regardless of the token.
The permission mode decides whether file edits are auto-approved. Accept-edits is the sensible choice in CI, where there is nobody to answer a prompt.
Put your conventions in a CLAUDE.md at the repository root rather than in the job's prompt. Claude reads it during every run, so review criteria, coding standards and the things you never want touched live in one reviewed file instead of being duplicated across a review job, an implement job and a refactor job that will drift apart within a month.
What Claude is genuinely good at in a pipeline
The useful framing is that a CI job is a place for work with a clear input and a reviewable output. Anything needing judgement about product direction belongs in a session where you can steer it.
Three shapes that work well, and what triggers each:
Task
Trigger
Output
Turn an issue into a merge request
A mention in an issue comment
A branch and an MR for review
Fix a named bug
A mention naming the error and component
A commit on the existing branch, or a new MR
Respond to review feedback
A follow-up comment on the MR
Further commits on the same branch
Keeping the bill proportionate
A CI job that can loop is a CI job that can spend, and there are three independent brakes worth setting from the first run rather than the first invoice:
The turns flag caps back-and-forth iterations, which is the one that actually bounds token spend.
GitLab's own job-level timeout caps wall-clock time and therefore runner minutes, which are billed separately from tokens.
Concurrency limits cap how many of these can run at once, which matters most on a busy repository where ten MRs can trigger ten jobs.
Specific mentions cost less than vague ones. Asking Claude to fix the type error in the user dashboard component gives it a target; asking it to improve the code gives it a search. The turns flag will eventually stop a vague run, but it stops it by running out of budget rather than by finishing, and you pay for the difference either way.
The GitLab integration is worth setting up if you already live in merge requests, and the setup is genuinely small — one job, one variable, and a listener if you want mentions. What decides whether it earns its keep is not the pipeline configuration but the CLAUDE.md next to it: a job with clear conventions produces MRs you merge, and a job without them produces MRs you close. Get that file right before you tune anything in the YAML.