Mert Özen Further With Every Line
DevOps Azure DevOps Pipelines — #08

Azure Pipelines Agents and Security: Microsoft-Hosted, Self-Hosted, Managed DevOps Pools and Protected Resources

Mert Özen Nov 15, 2026 10 min 5 views
Azure Pipelines Agents and Security: Microsoft-Hosted, Self-Hosted, Managed DevOps Pools and Protected Resources

A Microsoft-hosted agent gives you a fresh machine for every job, but the free tier has a single parallel job and a 60-minute limit; for more you need self-hosted agents or Managed DevOps Pools. We build a one-job container agent for Bookstore and cover the image calendar, job authorization scope, protected resources, restricting variables with settableVariables, and the removal dates of the Node.js runners.

Version note: The self-hosted agent image in this post was built and run locally with Docker 29; the pipeline and template files were parsed and checked with yq 4.54. How the agent options, Managed DevOps Pools, parallel job limits and security settings behave is based on the official documentation on Microsoft Learn (November 2026 versions).

Guess First

In the CI pipeline of Bookstore, the online bookstore used as the shared example across this site's series, the Build stage starts with four jobs: after Version finishes, Api and Web run in parallel, and Audit depends on nothing. The team has opened a new Azure DevOps organization, bought nothing, and enabled the Microsoft-hosted free tier. Do these three jobs run at the same time, and what happens if one job takes 70 minutes?

They don't. According to the documentation, the free tier gives private projects a single Microsoft-hosted parallel job: at most 60 minutes each time, 1,800 minutes (30 hours) per month in total. Parallel jobs are shared at organization level, across all projects and pipelines. Writing dependsOn: [] in YAML says the jobs may run in parallel, not that they will; with a single parallel job, Api, Web and Audit queue up. A 70-minute job is cut off at minute 60 on the free tier; with a paid parallel job the limit is 360 minutes per job. To get the free tier you link the organization to an Azure subscription and set up billing; the first parallel job you buy doesn't raise the count to two, it only removes the time limits of the first job.

Microsoft-Hosted Agents: Images and Limits

A Microsoft-hosted agent means a fresh virtual machine for every job: when the job ends the machine is discarded, and the next job sees nothing the previous one left on the file system. Linux and Windows machines are Azure virtual machines with 2 cores, 7 GB of RAM and a 14 GB SSD; at least 10 GB of free space is available to the pipeline. This hardware can't be increased; if you need more powerful machines, the way forward is a self-hosted agent or Managed DevOps Pools.

As of November 9, 2026, the documentation lists the image labels like this: ubuntu-latest points to ubuntu-24.04; ubuntu-26.04 became generally available on September 29, 2026; ubuntu-22.04 is deprecated and will be removed on April 17, 2027, with brownouts planned before that. On the Windows side, windows-latest currently points to windows-2025; the windows-2025-vs2026 image with Visual Studio 2026 became generally available on September 29, 2026, and according to the documentation windows-latest will move to it in the coming weeks, while windows-2025 also starts using Visual Studio 2026. windows-2019 was retired on December 31, 2025. This churn is why this series writes vmImage: ubuntu-24.04: a -latest label can move to a different operating system one day without warning.

There are two network notes. Hosted agents get no public IP address, so nothing outside can reach them. But when they need to reach a resource in the company network, you have to open the IP ranges of every Azure region in the organization's geography; the documentation points out that these ranges are wide and include other customers' machines too.

A Self-Hosted Agent: In a Container, One Job Each

A self-hosted agent is the agent software running on your own machine or container. For authentication at registration, the documentation lists three ways in Azure DevOps Services: a personal access token, device code flow and a service principal. Registration happens once; in its daily work the agent doesn't use the identity of the person who registered it.

For Bookstore we prepared an agent image based on Ubuntu 24.04. We started from the container agent example in the documentation and simplified it:

# Self-hosted Azure Pipelines agent for Bookstore builds. One container runs exactly one job (see start.sh).
FROM ubuntu:24.04

ENV TARGETARCH="linux-x64"

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl git jq libicu74 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /azp
COPY ./start.sh ./
RUN chmod +x ./start.sh \
    && useradd -m -d /home/agent agent \
    && chown -R agent:agent /azp /home/agent

# The agent runs as a non-root user without sudo.
USER agent
ENTRYPOINT ["./start.sh"]
#!/usr/bin/env bash
# Ephemeral agent: registers itself, runs exactly one job (--once) and removes its registration on exit.
# The orchestrator starts a fresh container for the next job, so nothing survives from one job to the next.
set -euo pipefail

: "${AZP_URL:?AZP_URL is required, for example https://dev.azure.com/bookstore-org}"
: "${AZP_TOKEN_FILE:?AZP_TOKEN_FILE is required: a mounted file that holds the registration token}"
pool="${AZP_POOL:-bookstore-linux}"
agent_name="${AZP_AGENT_NAME:-bookstore-$(hostname)}"

# A shell variable, not exported: jobs started by run.sh never see the token.
token="$(cat "$AZP_TOKEN_FILE")"

package_url=$(curl -fsS -u "agent:$token" \
  "$AZP_URL/_apis/distributedtask/packages/agent?platform=$TARGETARCH&top=1" | jq -r '.value[0].downloadUrl')
curl -fsSL "$package_url" | tar -xz

cleanup() {
  VSTS_AGENT_INPUT_TOKEN="$token" ./config.sh remove --unattended --auth pat || true
}
trap cleanup EXIT

# config.sh reads --token from VSTS_AGENT_INPUT_TOKEN, so the token never appears on a command line.
VSTS_AGENT_INPUT_TOKEN="$token" ./config.sh --unattended --url "$AZP_URL" --auth pat \
  --pool "$pool" --agent "$agent_name" --replace --work _work

./run.sh --once

Three decisions are in there. run.sh --once shuts the agent down after a single job; when the container restarts, a clean agent comes up. According to the documentation, on a self-hosted agent only the artifact staging and test results folders are cleaned between runs by default; on a long-lived agent, a file one job leaves behind stays for the next. As the documentation recommends, the agent runs as a separate user without sudo rights. And the token is read from a mounted file rather than an environment variable; because config.sh also accepts every command-line parameter as an environment variable with the VSTS_AGENT_INPUT_ prefix, the token is never written on a command line. We built the image locally and ran it without settings:

$ docker run --rm bookstore-agent:dev
./start.sh: line 6: AZP_URL: AZP_URL is required, for example https://dev.azure.com/bookstore-org
$ docker run --rm -e AZP_URL=https://dev.azure.com/bookstore-org bookstore-agent:dev
./start.sh: line 7: AZP_TOKEN_FILE: AZP_TOKEN_FILE is required: a mounted file that holds the registration token
$ docker run --rm --entrypoint id bookstore-agent:dev
uid=1001(agent) gid=1001(agent) groups=1001(agent)

The tools the application and tests need (.NET SDK, Node.js) aren't in the image; UseDotNet@2 and UseNode@1 in the pipeline install them in every job. Baking them into the image speeds jobs up but leaves keeping the image current to you.

Managed DevOps Pools

For anyone who wants their own agents to scale automatically, the old way was Virtual Machine Scale Set agent pools. Managed DevOps Pools is their successor: the Azure DevOps blog announced it as generally available on November 18, 2024. The roadmap says it plainly: features for new scenarios are added to Managed DevOps Pools, not to Scale Set pools, and moving Scale Set pools over is recommended.

The features in the documentation answer the limits of hosted agents directly: the VM size you want, images with the same software as the Microsoft-hosted images or your own image, network settings that can reach the company network or allow only specific addresses, up to two days per job, and keeping agent state for up to seven days to benefit from caches. The machines run in a subscription managed by Microsoft, not in yours; you manage the pool as an Azure resource. Since the September 2026 release, a pool can define up to five VM sizes (Instance Mix); spot virtual machine support sits on the roadmap without a date. If Bookstore's production database moves behind a private endpoint, this is the natural choice for the stage that runs the migration bundle: route only that stage to the pool with pool: name: bookstore-mdp.

Job Authorization Scope and the Build Identity

When we covered feed permissions in the fifth post, we mentioned two build identities: Project Collection Build Service (bookstore-org) at organization level and Bookstore Build Service (bookstore-org) at project level. For every job, Azure Pipelines generates a job access token; its permissions depend on the job authorization scope and on the permissions granted to these identities. With the scope set to collection, a job can reach the repositories of every project in the organization; with project, only those of its own project.

The documentation's advice is clear: turn on "Limit job authorization scope to current project for non-release pipelines" under Organization settings. Once it's on at organization level, projects can't turn it off; the documentation explains why: without the restriction, someone who takes over a single pipeline can reach every repository in the organization. There is a separate setting for release pipelines. On top of that, with "Protect access to repositories in YAML pipelines" on, a job reaches only the repositories it references explicitly through checkout or uses; this setting is on by default for organizations and projects created after May 2020. Bookstore's CI defines the pipeline-templates repository under resources, so it isn't affected by the setting.

Protected Resources and Pipeline Permissions

Agent pools, environments, service connections, repositories, variable groups with secrets and secure files are protected resources. Each has two layers: pipeline permissions, which decide which pipelines may use it, and checks, which decide what must hold before it's used. When a pipeline uses a resource for the first time, the run stops until permission is granted. The "Open access" option allows every pipeline in the project; that's reasonable for resources with no secrets and no production access. Managed DevOps Pools also require each pipeline to be authorized separately by default; open access was added as an option in April 2025.

The documentation has a warning about classic pipelines too: because YAML and classic pipelines can share the same service connections, an organization that uses only YAML is advised to disable the creation of classic build and release pipelines. With both disabled, task groups and deployment groups can't be created either. We'll take this up in the ninth post as part of the migration plan.

Restricting Variables: settableVariables and the Queue-Time Setting

As we saw in the second post, a script can define a new variable or change the value of an existing one with ##vso[task.setvariable]. The documentation's example shows the risk: if a later step puts $(MyConfig) inside a command, an earlier step that changes its value can add whatever it wants to that command. In version v1.5.0 of the templates we restricted this at step level:

          - script: bash ${{ parameters.testDbScript }}
            displayName: Start SQL Server for integration tests
            # The script may set only this variable; anything else it tries to set is ignored with a warning.
            target:
              settableVariables:
                - TestDbConnection
          - script: |
              dotnet list ${{ parameters.solution }} package --vulnerable --include-transitive | tee audit.txt
              if grep -q "has the following vulnerable packages" audit.txt; then
                echo "##vso[task.logissue type=error]Vulnerable packages found."
                exit 1
              fi
            displayName: Fail on vulnerable packages
            target:
              settableVariables: none

According to the documentation, when a step tries to set a variable that isn't on the list, the variable isn't set and a warning appears on the run page; none allows no variable to be set at all. We added none to the verification, migration and smoke test steps of the deploy template as well; none of them needs to produce a variable.

Variables added when a run is started are a separate door. With "Limit variables that can be set at queue time" on, users can change only variables marked "Let users override this value when running this pipeline" and can't add new ones; an attempt through the REST API gets an error. Making a variable defined in YAML read-only with readonly: true and turning on "Enable shell tasks arguments validation" are on the same list. When you need a choice from the user, a runtime parameter with a values list instead of a variable is the safest way: as the documentation stresses, a running pipeline can't change a parameter.

In Production

Track the Node.js versions of your tasks. According to the documentation, the official tasks currently use the Node.js 20 handler; support for Node.js 20 in Azure Pipelines ended in April 2026 and its removal from the agent is due in April 2027. Node.js 6, 10 and 16 are removed from the agent on November 24, 2026, and the agent already includes Node.js 24. If you use an old task from the Marketplace, turning on "Restrict out of support Node.js versions in pipeline tasks" under Organization settings and reading the warnings in the logs shows the impact in advance. On the roadmap, moving the built-in tasks to Node.js 24 is an item without a date.

Use fewer PATs. The roadmap's credential theft initiative aims to reduce the need for stealable secrets like PATs and to move towards Entra ID tokens. Registering agents with a service principal, the federated connections from the seventh post and the build identity's own token are steps in that direction.

Don't rely on secret masking. The documentation says Azure Pipelines tries to scrub secrets from logs but that this isn't foolproof, and it advises against putting a secret inside a structured value such as JSON; individual secrets are masked more accurately.

Common Pitfalls

Forgetting the previous job's files on a long-lived self-hosted agent. By default only two folders are cleaned. If you use a long-lived agent, add workspace: clean: all to the job, or start a fresh agent for every job as Bookstore does.

Opening a single self-hosted pool to every project. The documentation says several projects using the same agent in turn carries a risk of lateral movement between projects and recommends a separate pool per project. And never run pull requests from forks on self-hosted agents.

Giving secrets to fork builds. By default, pull request validations from forks get no secrets and no protected resources; turning on "Make secrets available to builds of forks" removes that protection.

Running the agent as a privileged user. An agent running with an Entra ID identity can reach Azure DevOps APIs without needing the job token; a compromised pipeline can then reach the whole organization.

A Small Experiment

★☆☆ Add settableVariables to the step that runs set-version.sh and allow only appVersion. Check in the documentation how output variables behave with this list.

★★☆ Add the .NET 10 SDK to the agent image, and compare the image size and a job's duration with the UseDotNet@2 version.

★★★ Write an agent strategy for Bookstore: which stages should run on Microsoft-hosted agents and which on Managed DevOps Pools? Justify it in terms of network, cost and security.

The pipelines are in YAML, the deployments are passwordless, and the agents and settings are locked down. In the last post of the series we look back: how to move from classic build and release pipelines to YAML, and what has changed in Azure Pipelines since 2023.