Appearance
Docker Build Pipeline
Backend services are packaged as Docker images using multi-stage Dockerfiles. The Azure DevOps pipeline builds only changed services (using git diff), authenticates to the private NuGet feed via a mounted secret, and pushes to the environment's ACR.
Multi-Stage Dockerfile
All backend services use the same Dockerfile pattern with two stages: build (SDK) and runtime (ASP.NET runtime only):
dockerfile
# ── Stage 1: Build ───────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy solution and project files first (layer caching)
COPY *.sln .
COPY Src/**/*.csproj ./
RUN dotnet restore --use-lock-file
# Copy source and build
COPY Src/ ./Src/
RUN dotnet publish \
Src/AppsPortal/Accounting/AppsPortal.Apis/AppsPortal.Apis.csproj \
--configuration Release \
--output /app/publish \
--no-restore
# ── Stage 2: Runtime ─────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
# Non-root user for security
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8080/health/live || exit 1
ENTRYPOINT ["dotnet", "AppsPortal.Apis.dll"]Non-Root Container
The USER appuser instruction ensures the container process does not run as root. This satisfies Hadolint rule DL3002 and Trivy IaC check AVD-DS-0002.
NuGet Private Feed Authentication
Backend services depend on private NuGet packages (Microtec.Domain, Microtec.Persistence, Microtec.Web.Core, etc.) hosted in the Azure DevOps Artifacts feed. The pipeline authenticates using $(System.AccessToken) — the built-in pipeline identity token.
This is passed to the Docker build via --mount=type=secret:
dockerfile
# In the build stage — uses BuildKit secret mount
RUN --mount=type=secret,id=nuget_pat \
dotnet nuget add source \
"https://pkgs.dev.azure.com/microtec/_packaging/MicrotecPackages/nuget/v3/index.json" \
--name MicrotecFeed \
--username az \
--password "$(cat /run/secrets/nuget_pat)" \
--store-password-in-clear-text && \
dotnet restore --use-lock-filePipeline YAML:
yaml
- task: Bash@3
displayName: 'Build and Push Docker Image'
inputs:
script: |
# Enable BuildKit
export DOCKER_BUILDKIT=1
docker build \
--secret id=nuget_pat,env=SYSTEM_ACCESSTOKEN \
--build-arg BUILD_CONFIG=Release \
--tag $(acrName).azurecr.io/$(serviceName):$(Build.BuildId) \
--tag $(acrName).azurecr.io/$(serviceName):latest \
--file $(servicePath)/Dockerfile \
$(Build.SourcesDirectory)
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)NuGet PAT Does Not Expire
$(System.AccessToken) is the built-in Azure DevOps pipeline identity. It uses OAuth 2.0 and auto-renews with each pipeline run. Do NOT hardcode a personal PAT — they expire and are tied to individual accounts.
Change Detection
The build-and-push.yml template uses git diff to identify which services have changed since the last commit, building only those services:
bash
#!/bin/bash
CHANGED_PATHS=$(git diff --name-only HEAD~1 HEAD)
while IFS= read -r service; do
SERVICE_NAME=$(echo "$service" | jq -r '.name')
SOURCE_PATH=$(echo "$service" | jq -r '.sourcePath')
if echo "$CHANGED_PATHS" | grep -q "^${SOURCE_PATH}"; then
echo "##vso[task.setvariable variable=BUILD_${SERVICE_NAME}]true"
echo "Change detected in: $SERVICE_NAME (path: $SOURCE_PATH)"
else
echo "##vso[task.setvariable variable=BUILD_${SERVICE_NAME}]false"
echo "No change: $SERVICE_NAME — skipping build"
fi
done < <(jq -c '.services[]' services-config.json)This is skipped in the fast-deployment orchestrator where the target service is explicitly specified.
Full build-and-push.yml Template
Location: Devops/azure/templates/containerBackend/build/build-and-push.yml
yaml
parameters:
- name: environment
type: string
- name: serviceName
type: string
- name: sourcePath
type: string
- name: imageTag
type: string
default: $(Build.BuildId)
- name: acrName
type: string
steps:
- task: Bash@3
displayName: 'Build Docker: ${{ parameters.serviceName }}'
inputs:
script: |
export DOCKER_BUILDKIT=1
docker build \
--secret id=nuget_pat,env=SYSTEM_ACCESSTOKEN \
--build-arg SERVICE_NAME=${{ parameters.serviceName }} \
--tag ${{ parameters.acrName }}.azurecr.io/${{ parameters.serviceName }}:${{ parameters.imageTag }} \
--cache-from ${{ parameters.acrName }}.azurecr.io/${{ parameters.serviceName }}:latest \
--file ${{ parameters.sourcePath }}/Dockerfile \
$(Build.SourcesDirectory)
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
- template: ../security/trivy-image.yml
parameters:
imageName: ${{ parameters.acrName }}.azurecr.io/${{ parameters.serviceName }}:${{ parameters.imageTag }}
- task: AzureCLI@2
displayName: 'Push to ACR: ${{ parameters.serviceName }}'
inputs:
azureSubscription: 'mic-erp-${{ parameters.environment }}-sc'
script: |
az acr login --name ${{ parameters.acrName }}
docker push ${{ parameters.acrName }}.azurecr.io/${{ parameters.serviceName }}:${{ parameters.imageTag }}
docker push ${{ parameters.acrName }}.azurecr.io/${{ parameters.serviceName }}:latest
echo "Pushed: ${{ parameters.serviceName }}:${{ parameters.imageTag }}"Image Tagging Strategy
| Environment | Primary Tag | Also Tagged As |
|---|---|---|
| dev | {branch}-{build-id} e.g. feat-invoice-1042 | latest |
| stage | stage-{build-id} | latest |
| preprod | {version}-rc{n} e.g. 1.5.0-rc2 | latest |
| uat | {version}-rc{n} | latest |
| production | {version} e.g. 1.5.0 | latest, stable |
The imageTag pipeline variable is set at the start of each orchestrator run:
yaml
variables:
- name: imageTag
${{ if eq(variables['Build.SourceBranchName'], 'production') }}:
value: '$(semanticVersion)'
${{ else }}:
value: '$(Build.SourceBranchName)-$(Build.BuildId)'Build Cache
Docker layer caching is enabled using --cache-from pointing to the :latest tag in ACR. This significantly reduces build time when only application code (not dependencies) changes:
Without cache: dotnet restore ≈ 90 seconds
With cache: dotnet restore ≈ 5 seconds (layers cached)The --cache-from flag pulls the latest image layers from ACR before building. This requires that az acr login runs before docker build.
Keycloak Image Build
The custom Keycloak image is built and pushed using the same template but with a different Dockerfile path and no NuGet dependency:
yaml
- template: templates/containerBackend/build/build-and-push.yml
parameters:
environment: $(environment)
serviceName: keycloak
sourcePath: KeycloakProviders/docker
imageTag: $(Build.BuildId)
acrName: $(acrName)
# No nuget_pat needed — Keycloak image uses Maven, not .NETTroubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
401 Unauthorized on NuGet restore | SYSTEM_ACCESSTOKEN not passed to Docker | Verify env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) in pipeline task |
Docker build fails: secret not found | BuildKit not enabled | Add export DOCKER_BUILDKIT=1 before docker build |
Image push fails: unauthorized | ACR login not run | Add az acr login --name {acrName} before docker push |
| Build takes 10+ minutes every run | Cache-from not working | Verify :latest tag exists in ACR; check az acr login output |
| Trivy blocks build on known false positive | CVE not in .trivyignore | Add CVE to .trivyignore with date, reviewer, and justification |
Related Documentation
- Container Registry — ACR setup and managed identity
- Stage Templates — How
build-and-push.ymlis composed - Trivy — Image scan integrated into build template
- Services Config —
sourcePathfor each service - Hadolint — Dockerfile linting pre-build