Building Docker Images with Azure Pipelines
Building container images in a CI pipeline is one of the most common tasks in modern software delivery. This page shows how to write an Azure Pipelines YAML file that builds a Docker image, tags it with the commit SHA, scans it for vulnerabilities, and pushes it to Azure Container Registry on every merge to main.
Setting up Azure Container Registry
Before writing any pipeline code, you need an ACR instance and a service connection in Azure DevOps that grants the pipeline permission to push images. The fastest way to connect them is the Docker Registry service connection type, which uses an admin token or a Service Principal.
# Create an ACR instance
az acr create \
--resource-group myapp-rg \
--name myappregistry \
--sku Basic \
--admin-enabled false
# Get the ACR login server
az acr show --name myappregistry --query loginServer -o tsv
# Output: myappregistry.azurecr.io
# Grant a Service Principal the AcrPush role
SP_ID=$(az ad sp show --id http://azure-pipelines-sp --query id -o tsv)
ACR_ID=$(az acr show --name myappregistry --query id -o tsv)
az role assignment create \
--assignee $SP_ID \
--scope $ACR_ID \
--role AcrPush
Do not enable the ACR admin account in production. The admin account gives root access to the entire registry and cannot be scoped. Use the AcrPush RBAC role for pipelines and AcrPull for services that pull images.
A production-ready multi-stage Dockerfile
A multi-stage Dockerfile keeps the final image small by separating the build environment from the runtime environment. The builder stage installs compilers and dev dependencies. The final stage copies only the compiled output.
# Dockerfile — Node.js multi-stage build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
Basic Docker build pipeline
The simplest pipeline uses the Docker@2 task to build and push in one step. This task handles login, build, tag, and push using a Docker Registry service connection.
# azure-pipelines.yml — basic Docker build and push
trigger:
branches:
include:
- main
paths:
exclude:
- '**/*.md'
variables:
REGISTRY: 'myappregistry.azurecr.io'
IMAGE_NAME: 'myapp/api'
TAG: '$(Build.SourceVersion)' # full git SHA
SHORT_TAG: $(Build.SourceVersion:0:8) # not valid YAML — use a script step
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@2
displayName: 'Build and push image'
inputs:
command: buildAndPush
repository: '$(IMAGE_NAME)'
containerRegistry: 'MyACRServiceConnection' # name of the service connection
dockerfile: '$(Build.SourcesDirectory)/Dockerfile'
buildContext: '$(Build.SourcesDirectory)'
tags: |
$(Build.BuildId)
latest
Advanced pipeline: short SHA tags and image scanning
For production pipelines, you want to tag images with a short git SHA, push to a staging registry first, scan the image, and only push to production after the scan passes. This pattern prevents vulnerable images from reaching production environments.
# azure-pipelines.yml — advanced Docker pipeline with scanning
trigger:
branches:
include:
- main
variables:
REGISTRY: 'myappregistry.azurecr.io'
IMAGE_NAME: 'myapp/api'
IMAGE_TAG: '' # set in a script step
pool:
vmImage: 'ubuntu-latest'
stages:
- stage: Build
displayName: 'Build Docker Image'
jobs:
- job: BuildAndPush
displayName: 'Build, scan, and push'
steps:
- checkout: self
fetchDepth: 0
# Set short SHA as a variable for subsequent steps
- bash: |
SHORT_SHA=$(git rev-parse --short=8 HEAD)
BUILD_TAG="${SHORT_SHA}-$(Build.BuildId)"
echo "##vso[task.setvariable variable=IMAGE_TAG]$BUILD_TAG"
echo "Image tag: $BUILD_TAG"
displayName: 'Set image tag'
- task: Docker@2
displayName: 'Build image'
inputs:
command: build
repository: '$(IMAGE_NAME)'
containerRegistry: 'MyACRServiceConnection'
dockerfile: 'Dockerfile'
buildContext: '.'
tags: |
$(IMAGE_TAG)
latest
arguments: '--build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")'
# Scan the image with Trivy before pushing
- bash: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image \
--exit-code 1 \
--severity HIGH,CRITICAL \
--no-progress \
"$(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)"
displayName: 'Scan image for vulnerabilities'
- task: Docker@2
displayName: 'Push image to ACR'
inputs:
command: push
repository: '$(IMAGE_NAME)'
containerRegistry: 'MyACRServiceConnection'
tags: |
$(IMAGE_TAG)
latest
# Publish image tag as a pipeline variable for downstream stages
- bash: |
echo "##vso[task.setvariable variable=PUBLISHED_TAG;isOutput=true]$(IMAGE_TAG)"
name: publishTag
displayName: 'Publish image tag as output variable'
- stage: Deploy_Dev
displayName: 'Deploy to Dev'
dependsOn: Build
variables:
PUBLISHED_TAG: $[ stageDependencies.Build.BuildAndPush.outputs['publishTag.PUBLISHED_TAG'] ]
jobs:
- deployment: DeployDev
environment: 'dev'
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
displayName: 'Update container app image'
inputs:
azureSubscription: 'My Azure Subscription'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az containerapp update \
--name myapp-dev \
--resource-group myapp-dev-rg \
--image "$(REGISTRY)/$(IMAGE_NAME):$(PUBLISHED_TAG)"
Speeding up builds with layer caching
Docker layer caching significantly reduces build times. The problem with ephemeral pipeline agents is that the cache disappears between runs. You can work around this using Docker’s built-in cache-from flag, pointing at the previous image in ACR as the cache source.
# Use the previous latest image as a build cache
- bash: |
REGISTRY="myappregistry.azurecr.io"
IMAGE="${REGISTRY}/myapp/api"
# Log in to ACR
az acr login --name myappregistry
# Pull the previous image as cache (ignore failure if first run)
docker pull "${IMAGE}:latest" || true
# Build with cache-from
docker build \
--cache-from "${IMAGE}:latest" \
--tag "${IMAGE}:$(IMAGE_TAG)" \
--tag "${IMAGE}:latest" \
.
# Push both tags
docker push "${IMAGE}:$(IMAGE_TAG)"
docker push "${IMAGE}:latest"
displayName: 'Build with layer cache'
Alternative: az acr build (no Docker on agent required)
The az acr build command streams your build context to ACR and builds the image on ACR’s own build agents. This means you do not need Docker installed on your pipeline agent — useful for environments with restricted agent configurations.
- task: AzureCLI@2
displayName: 'Build and push with ACR Tasks'
inputs:
azureSubscription: 'My Azure Subscription'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az acr build \
--registry myappregistry \
--image "myapp/api:$(Build.BuildId)" \
--image "myapp/api:latest" \
--file Dockerfile \
.
Common mistakes
- Tagging only with “latest”. The
latesttag is mutable — it gets overwritten on every push. If a bad image is pushed, you lose the ability to identify which image is running where. Always tag with an immutable identifier (commit SHA or build ID) and uselatestonly as a secondary convenience tag. - Including .git and node_modules in the build context. Sending a large build context to the Docker daemon slows every build. Create a
.dockerignorefile that excludes.git/,node_modules/,*.log, and any other files not needed at build time. - Running the container as root. The default Docker user is root. If an attacker escapes the container, they have root on the host. Always create a non-root user in the Dockerfile and switch to it with
USERbefore the finalCMD.
Summary
- Azure Pipelines uses the Docker@2 task or the AzureCLI task with
az acr buildto build and push images to ACR. - Tag every image with an immutable identifier (short commit SHA or build ID) plus an optional mutable tag like latest.
- Multi-stage Dockerfiles reduce image size by separating the build environment from the runtime environment.
- Add a vulnerability scanning step (Trivy or ACR’s built-in scanning) between build and push to catch issues before they reach production.
- Use a
.dockerignorefile to keep the build context small and prevent secrets from leaking into the image layers.
Frequently asked questions
Do I need Docker installed on the pipeline agent to build images?
Yes for Docker daemon-based builds. Microsoft-hosted ubuntu-latest agents come with Docker pre-installed. For Windows agents, Docker Desktop must be installed. Alternatively, you can use tools like Kaniko or BuildKit in daemonless mode for building inside containers, which is more secure.
How should I tag Docker images in CI/CD?
Tag every image with a unique identifier that traces back to the source commit. Common patterns are the git commit SHA ($(Build.SourceVersion) or the short 8-character form), the build number, or a semantic version from a tag. Always push a second immutable tag alongside a mutable tag like "latest". Never rely on "latest" alone in CI/CD.
How do I push to Azure Container Registry from a pipeline?
Create a service connection of type Docker Registry pointing to your ACR. In your pipeline, use the Docker@2 task with the service connection name. The task handles authentication, build, and push. Alternatively, use the AzureCLI task to run az acr build, which builds and pushes without needing Docker on the agent.