Skip to main content

Azure Pipelines

Run Infracost in Azure Pipelines to post cloud cost estimates and FinOps best practices on every pull request.

tip

If your code is in Azure Repos, the Azure Repos App needs no pipeline configuration and is the recommended path. Use this page when you need the estimate to run inside your own pipeline.

Azure Pipelines builds two different repository hosts, and they differ in how a pull request build is triggered and which token comments. Both use the same job container.

Azure ReposGitHub-backed
PR build triggerBuild validation branch policypr: in the YAML
Comment tokenSYSTEM_ACCESSTOKENGITHUB_TOKEN
Providerazure_reposgithub

BUILD_REPOSITORY_PROVIDER is what Infracost reads to tell them apart, so neither recipe declares a provider. TfsGit, GitHub and GitHubEnterprise are recognized. A pipeline backed by Bitbucket or an external Git remote infers nothing, so set INFRACOST_VCS_PROVIDER and INFRACOST_VCS_REPOSITORY_URL yourself there.

Add your CLI token​

Under Pipelines → your pipeline → Edit → Variables, add INFRACOST_CLI_AUTHENTICATION_TOKEN with your Infracost CLI token and tick Keep this value secret. Both recipes below map it into the job.

Azure leaves $(INFRACOST_CLI_AUTHENTICATION_TOKEN) unexpanded if the variable does not exist, and the run then fails with a 401 from the Infracost API rather than a missing-token error.

Azure Repos​

trigger:
branches:
include:
- main

# No pr: trigger, because Azure Repos ignores it. PR builds come from a Build validation
# branch policy on the target branch, which still sets Build.Reason=PullRequest.

jobs:
- job: infracost
# diff is a pull request run: a build of the default branch has no target
# branch to fetch. Run scan --path . there instead.
condition: eq(variables['Build.Reason'], 'PullRequest')
container: ghcr.io/infracost/ci:2
steps:
- checkout: self
fetchDepth: 0
# The default drops the auth header, so the fetch below cannot reach a
# private repository.
persistCredentials: true
- script: |
# A self-hosted agent reuses the workspace, which still has the last
# run's worktrees registered.
git worktree prune
BRANCH="${TARGET_BRANCH#refs/heads/}"
git fetch origin "$BRANCH"
git worktree add base "origin/$BRANCH"
git worktree add head HEAD
env:
# Mapped, not written into the script: Azure substitutes $(...) into
# the script text, where bash would parse the branch name as code.
TARGET_BRANCH: $(System.PullRequest.TargetBranch)
- script: infracost-ci diff --base-path base --head-path head
env:
INFRACOST_CLI_AUTHENTICATION_TOKEN: $(INFRACOST_CLI_AUTHENTICATION_TOKEN)
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
caution

A pr: trigger is a GitHub and Bitbucket Cloud feature. On Azure Repos it is ignored silently and no pull request ever queues a build. Wire the pipeline up as a branch policy instead: Repos → Branches → main → Branch policies → Build validation.

SYSTEM_ACCESSTOKEN must be mapped explicitly, because Azure does not expose System.AccessToken to a step otherwise.

Azure has no pipeline variable for the pull request title or author, so diff reads them from the pull request itself. That needs credentials to post the comment and read those two fields. Use either option:

  • Build identity. Go to Project Settings → Repositories → the repo → Security, search Build Service, and set Contribute to pull requests to Allow. With Limit job authorization scope to current project disabled, the pipeline runs as Project Collection Build Service instead, so grant it there.
  • Personal access token. Set AZURE_DEVOPS_EXT_PAT. A 52-character PAT is sent as Basic auth; any other token goes out as a bearer token.

GitHub-backed repository​

The pull request lives on GitHub, so the comment goes through the GitHub API and SYSTEM_ACCESSTOKEN is not involved.

# The baseline and merged-status jobs below run from this trigger. Set it to
# none and they never fire — only the pr: trigger would queue a build.
trigger:
branches:
include:
- main

pr:
branches:
include:
- main

jobs:
- job: infracost
condition: eq(variables['Build.Reason'], 'PullRequest')
container: ghcr.io/infracost/ci:2
steps:
- checkout: self
fetchDepth: 0
persistCredentials: true
- script: |
# A self-hosted agent reuses the workspace, which still has the last
# run's worktrees registered.
git worktree prune
BRANCH="${TARGET_BRANCH#refs/heads/}"
git fetch origin "$BRANCH"
git worktree add base "origin/$BRANCH"
git worktree add head HEAD
env:
# TargetBranchName is Azure Repos only. Mapped rather than written
# into the script, which Azure substitutes into before bash parses it.
TARGET_BRANCH: $(System.PullRequest.TargetBranch)
- script: infracost-ci diff --base-path base --head-path head
env:
INFRACOST_CLI_AUTHENTICATION_TOKEN: $(INFRACOST_CLI_AUTHENTICATION_TOKEN)
GITHUB_TOKEN: $(GITHUB_TOKEN)

GITHUB_TOKEN is a pipeline variable you set, marked Keep this value secret: a fine-grained token scoped to that one repository with Pull requests: Read and write. A classic PAT works, but its repo scope carries write access to every repository you can reach. Azure leaves $(GITHUB_TOKEN) unexpanded if no such variable exists, and diff refuses that literal rather than sending it to GitHub.

The title and author come from the pull request on GitHub, read with the same GITHUB_TOKEN that comments. Azure's own variables are no help here: it has none for the title, and BUILD_REQUESTEDFOR is the literal github rather than any person. Without a token both stay empty, so set INFRACOST_VCS_PULL_REQUEST_TITLE and INFRACOST_VCS_PULL_REQUEST_AUTHOR instead.

caution

A pr: trigger builds pull requests from forks. Leave Triggers → Pull request validation → Make secrets available to builds of forks off; turning it on hands a stranger's branch both tokens. persistCredentials: true also leaves the checkout token in the workspace's .git/config for the rest of the job, so drop it on a public repository, where the fetch needs no credential.

Track your default branch​

Add a job on the default branch so Infracost Cloud keeps baseline costs and issues current. This populates Issue Explorer and Visibility → Repos.

- job: infracost_baseline
condition: ne(variables['Build.Reason'], 'PullRequest')
container: ghcr.io/infracost/ci:2
steps:
- checkout: self
- script: infracost-ci scan --path .
env:
INFRACOST_CLI_AUTHENTICATION_TOKEN: $(INFRACOST_CLI_AUTHENTICATION_TOKEN)

scan takes a single directory, unlike diff.

Report merged pull requests​

Infracost Cloud calculates cost prevention (money saved by changes made before merging) from merged pull requests. Read the merge commit on the default branch, by appending this step to the infracost_baseline job above:

- job: infracost_baseline
condition: ne(variables['Build.Reason'], 'PullRequest')
container: ghcr.io/infracost/ci:2
steps:
- checkout: self
- script: infracost-ci scan --path .
env:
INFRACOST_CLI_AUTHENTICATION_TOKEN: $(INFRACOST_CLI_AUTHENTICATION_TOKEN)
- script: |
PR=$(git log -1 --pretty=%B | grep -oE '(Merged PR|Merge pull request) #?[0-9]+' | grep -oE '[0-9]+$') || true
[ -n "$PR" ] && infracost-ci status --status MERGED --pr-number "$PR" || echo "no PR in the commit message"
env:
INFRACOST_CLI_AUTHENTICATION_TOKEN: $(INFRACOST_CLI_AUTHENTICATION_TOKEN)

The same step serves both recipes. Three different merge commit subjects reach it, and the one pattern matches all three:

Who completed the pull requestSubject line
Azure Repos, through the web UIMerged PR 42: title
Azure Repos, through the API — including Renovate and Dependabot auto-completeMerge pull request 42 from …
GitHub, on a GitHub-backed repositoryMerge pull request #42 from …

It has to run on the default branch, not in the pull request job: git log -1 there reads the pull request head commit, which carries no merge subject at all.

caution

Fast-forward merges and customized merge commit templates break the pattern match. The pull request simply won't get merged status; nothing else fails, and nothing is logged, so check Infracost Cloud rather than the build.

Notes​

Azure starts the job container as <image> bash -c "sleep infinity" and execs the steps into it, so the entrypoint runs bash and sh as given and passes everything else to infracost-ci. No options: --entrypoint is needed.

Azure DevOps Server is derived from the repository URL. For a private certificate authority, point INFRACOST_CI_VCS_TLS_CA_CERT_FILE at a PEM bundle.

No Terraform binary and no plan file are needed: the parsers read your infrastructure code directly.

Guardrails and blocking​

Guardrails and policies evaluate on every run. When one blocks, the comment posts first and then diff exits 1, so the build goes red with the reason already on the pull request. An unblock from the dashboard takes effect on the next run.

On Azure Repos a Build validation branch policy marked Required does block completion — the same policy that queues the build in the first place. On a GitHub-backed repository, mark the check required on GitHub instead.

Your code stays local​

Code is parsed inside the agent. Only the cost estimate and the pull request metadata reach Infracost Cloud.