Skip to content

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-file

Pipeline 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

EnvironmentPrimary TagAlso Tagged As
dev{branch}-{build-id} e.g. feat-invoice-1042latest
stagestage-{build-id}latest
preprod{version}-rc{n} e.g. 1.5.0-rc2latest
uat{version}-rc{n}latest
production{version} e.g. 1.5.0latest, 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 .NET

Troubleshooting

SymptomLikely CauseResolution
401 Unauthorized on NuGet restoreSYSTEM_ACCESSTOKEN not passed to DockerVerify env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) in pipeline task
Docker build fails: secret not foundBuildKit not enabledAdd export DOCKER_BUILDKIT=1 before docker build
Image push fails: unauthorizedACR login not runAdd az acr login --name {acrName} before docker push
Build takes 10+ minutes every runCache-from not workingVerify :latest tag exists in ACR; check az acr login output
Trivy blocks build on known false positiveCVE not in .trivyignoreAdd CVE to .trivyignore with date, reviewer, and justification

Internal Documentation — Microtec Platform Team