← Back to Articles         
Deploy ASP.NET Core Web API to Azure App Service Using Docker and Azure DevOps CI/CD

Deploy ASP.NET Core Web API to Azure App Service Using Docker and Azure DevOps CI/CD

Published on 09 Nov 2026 24 min read Docker
Docker Fundamentals

Introduction

Docker allows developers to package an application and its dependencies into a standardized image. This image can run consistently across different environments without requiring the application to be installed directly on the host machine.

In this article, we will learn how to containerize an existing ASP.NET Core Web API application and deploy it to Azure App Service using Azure Container Registry (ACR).

We will also implement an automated CI/CD pipeline using Azure DevOps YAML.

Our implementation covers:

  1. Creating a Dockerfile for an ASP.NET Core Web API.

  2. Creating a Docker image using a multi-stage build.

  3. Running and testing the container locally.

  4. Creating Azure Container Registry.

  5. Pushing the Docker image to ACR.

  6. Creating Azure App Service for Linux containers.

  7. Configuring Managed Identity and Key Vault.

  8. Deploying the container to Azure App Service.

  9. Implementing Azure DevOps CI/CD using YAML.

  10. Troubleshooting common Docker and Azure deployment errors.

1. Understand the deployment architecture

Before starting, let's understand how the different components communicate.

                Developer Machine
                       |
                       |
                  Git Repository
                       |
                       |
                   Dockerfile
                       |
                       |
                 Docker Build
                       |
                       ▼
                Docker Image
                       |
                       |
                 Docker Push
                       |
                       ▼
           Azure Container Registry
                       |
                       |
                 Docker Pull
                       |
                       ▼
              Azure App Service
                       |
                       ▼
             ASP.NET Core Web API
                       |
                ┌──────┴──────┐
                |             |
                ▼             ▼
           Azure SQL      Azure Key Vault
           Database        Application Secrets

What is the responsibility of each component?

Component Responsibility
Dockerfile Defines how the application image is built.
Docker Image Contains the published application and required runtime.
Docker Container Runs the application from the image.
Azure Container Registry Stores Docker images in Azure.
Azure App Service Hosts and runs the Docker container.
Managed Identity Provides passwordless authentication between Azure services.
Azure Key Vault Stores sensitive application configuration.
Azure SQL Database Stores application data.
Azure DevOps Automates the build and deployment process.

Important: Docker Compose is not required for this deployment because we are deploying a single API container. Docker Compose is useful when multiple containers need to run together.

2. Project structure

For this demonstration, we will use an existing ASP.NET Core Portfolio API developed using .NET 8.

The solution directory is:

D:\Syed\Project 2026\Portfolio Project\Syed Portfolio Project\Azure Portfolio

The solution structure:

Azure Portfolio
│
├── Syed.PortfolioPlatform.sln
│
├── Syed.PortfolioPlatform.API
│   ├── Controllers
│   ├── Program.cs
│   └── Syed.PortfolioPlatform.API.csproj
│
├── Syed.PortfolioPlatform.Application
│
├── Syed.PortfolioPlatform.Domain
│
├── Syed.PortfolioPlatform.Infrastructure
│
├── Syed.PortfolioPlatform.Functions.Notifications
│
└── Syed.PortfolioPlatform.Web

We will deploy only the API project and its dependent class libraries.

3. Create Dockerfile

A Dockerfile contains instructions that Docker uses to build an image.

Create a file named Dockerfile in the solution root directory.

Add the following content:

# Stage 1: Build the application
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build

WORKDIR /src

# Copy source code
COPY . .

# Restore dependencies
RUN dotnet restore "Syed.PortfolioPlatform.API/Syed.PortfolioPlatform.API.csproj"

# Publish the API
RUN dotnet publish "Syed.PortfolioPlatform.API/Syed.PortfolioPlatform.API.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore

# Stage 2: Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final

WORKDIR /app

# Configure application listening port
ENV ASPNETCORE_HTTP_PORTS=8080

EXPOSE 8080

# Copy published application
COPY --from=build /app/publish .

# Start the API
ENTRYPOINT ["dotnet", "Syed.PortfolioPlatform.API.dll"]

Understand the Dockerfile

Instruction Purpose
FROM sdk:8.0 Provides the .NET 8 SDK for compiling the application.
WORKDIR /src Sets the working directory inside the build container.
COPY . . Copies the Docker build context into the container.
dotnet restore Downloads NuGet dependencies.
dotnet publish Compiles and publishes the API.
FROM aspnet:8.0 Creates the runtime image using ASP.NET Core runtime.
WORKDIR /app Sets the runtime working directory.
ENV ASPNETCORE_HTTP_PORTS=8080 Configures the API to listen on port 8080.
EXPOSE 8080 Documents the container's HTTP port.
ENTRYPOINT Starts the Portfolio API.

What is a multi-stage Docker build?

We use two stages:

Build stage: Uses the .NET SDK image to restore packages, compile and publish the application.

Runtime stage: Uses the lightweight ASP.NET Core runtime image to run the published application.

Source Code
     |
     ▼
.NET 8 SDK Image
     |
     ▼
Restore NuGet Packages
     |
     ▼
Compile and Publish
     |
     ▼
.NET 8 ASP.NET Runtime Image
     |
     ▼
Final Docker Image

The final image does not contain the complete .NET SDK, which reduces the runtime image size.

4. Create .dockerignore

When Docker executes COPY . ., it uses the build context to determine which files are sent to the Docker engine.

We should exclude unnecessary files.

Create .dockerignore in the solution root:

**/bin/
**/obj/
**/.vs/
.vs/
.vscode/
.git/
.github/
**/TestResults/
**/*.user
**/*.suo
.env
.env.*
**/secrets*.json

Syed.PortfolioPlatform.Web/
Syed.PortfolioPlatform.Functions.Notifications/

Why do we need .dockerignore?

It prevents unnecessary files from being included in the Docker build context.

Examples:

  • bin – Compiled application files.

  • obj – Intermediate build files.

  • .vs – Visual Studio temporary files.

  • .git – Git repository metadata.

  • .env – Environment files.

  • Web project – Not required for this API image.

  • Functions project – Not required for this API image.

This reduces build-context size and prevents unrelated project files from being copied into the image build.

Make sure the excluded projects are not required by the API project through project references.

5. Build the Docker image

Open PowerShell and navigate to the solution directory.

cd "D:\Syed\Project 2026\Portfolio Project\Syed Portfolio Project\Azure Portfolio"

Execute:

docker build -t syed-portfolio-api:v1 .

Understand the command

Parameter Description
docker build Builds a Docker image.
-t Assigns an image name and tag.
syed-portfolio-api Image name.
v1 Image version.
. Current directory as Docker build context.

Verify the image:

docker images syed-portfolio-api

Expected output:

REPOSITORY             TAG
syed-portfolio-api     v1

Our first Docker image has been created.

6. Run the Docker container locally

Before deploying to Azure, always test the image locally.

For this project, our API connects to SQL Server and Azure Key Vault.

We initially encountered an authentication issue because the Docker container was running without Azure credentials.

6.1 Configure the local environment

Our application loads Key Vault only when the environment is Production.

The existing Program.cs configuration:

if (builder.Environment.IsProduction())
{
    var vaultUri = builder.Configuration["KeyVault:VaultUri"];

    if (!string.IsNullOrEmpty(vaultUri))
    {
        builder.Configuration.AddAzureKeyVault(
            new Uri(vaultUri),
            new DefaultAzureCredential());
    }
}

For local Docker testing, we use:

ASPNETCORE_ENVIRONMENT=Development

This skips the Production-only Key Vault initialization.

6.2 Configure Windows SQL Server connectivity

Our local Windows SQL Server uses the following configuration:

Property Value
SQL Server Instance MSSQLSERVER
SQL Server Type Default Instance
TCP/IP Enabled
TCP Port 1433
Authentication SQL Server and Windows Authentication
Database ServerDB_Bak_121026

We also created a dedicated SQL Server login:

portfolio_docker_user

The login was granted access to the local development database with the permissions required by the application's EF Core migrations.

Why can't we use Server=. inside Docker?

When the API runs directly on Windows:

Server=.

refers to the local Windows SQL Server.

However, when the application runs inside a Docker container, . refers to the container itself.

For Docker Desktop on Windows, use:

host.docker.internal

This hostname allows the container to communicate with services running on the Windows host.

6.3 Configure the Docker connection string

For local Docker testing, use:

Server=host.docker.internal,1433;
Database=ServerDB_Bak_121026;
User ID=portfolio_docker_user;
Password=YOUR_PASSWORD;
Encrypt=True;
TrustServerCertificate=True;

Do not use the local Windows Integrated Authentication setting:

Trusted_Connection=True;

Our Linux-based Docker container uses the dedicated SQL Server login instead.

6.4 Run the container

Use PowerShell:

$securePassword = Read-Host "SQL Password" -AsSecureString

$sqlPassword = [Net.NetworkCredential]::new(
    "",
    $securePassword
).Password

$builder = [System.Data.Common.DbConnectionStringBuilder]::new()

$builder["Server"] = "host.docker.internal,1433"
$builder["Database"] = "ServerDB_Bak_121026"
$builder["User ID"] = "portfolio_docker_user"
$builder["Password"] = $sqlPassword
$builder["Encrypt"] = "True"
$builder["TrustServerCertificate"] = "True"

docker run -d `
    --name syed-portfolio-api-local `
    -e ASPNETCORE_ENVIRONMENT=Development `
    -e "ConnectionStrings__DefaultConnection=$($builder.ConnectionString)" `
    -p 8080:8080 `
    syed-portfolio-api:v1

Remove-Variable sqlPassword, securePassword, builder

This creates and starts the container.

The double underscore in:

ConnectionStrings__DefaultConnection

maps to the ASP.NET Core configuration hierarchy:

ConnectionStrings:DefaultConnection

Environment variables override the configuration values loaded from appsettings.json.

Note: This local approach is intended for development. Docker environment variables can be inspected by users with access to the Docker Engine. Do not store production secrets this way.

6.5 Understand port mapping

-p 8080:8080

The first port represents the Windows host port.

The second port represents the container port.

Windows Machine
      |
      | http://localhost:8080
      |
      ▼
Docker Container
      |
      ▼
ASP.NET Core API
      |
      ▼
Port 8080

6.6 Verify the running container

Execute:

docker ps

Check application logs:

docker logs syed-portfolio-api-local

Expected application log:

Starting DB Migration...
DB Migration completed

6.7 Test API locally

Open:

http://localhost:8080/swagger

Or test an API endpoint:

http://localhost:8080/api/articles/categories

We successfully verified the local container and received the expected API data.

7. Prepare the Docker image for Azure deployment

For a repeatable deployment, we should maintain the relationship between the source code and the Docker image.

We use Git commit-based image versioning.

7.1 Commit Docker configuration files

Stage the files:

git add Dockerfile .dockerignore

Verify:

git diff --cached --check

Commit:

git commit -m "Add Docker build configuration"

Verify:

git log -1 --oneline

7.2 Verify Git working directory

Execute:

git status --short

Expected result: no output, indicating a clean working directory.

7.3 Generate a Git-based Docker image tag

Execute:

$gitSha = (git rev-parse --short=12 HEAD).Trim()

$imageTag = "sha-$gitSha"

$localImage = "syed-portfolio-api:$imageTag"

Write-Host $localImage

Example:

syed-portfolio-api:sha-4c39725130e3

7.4 Build the versioned image

docker build --pull -t $localImage .

Verify:

docker images syed-portfolio-api

Using the Git SHA makes the image traceable to the source-code revision.

8. Create Azure Container Registry

Azure Container Registry is a managed private Docker image registry.

It stores Docker images that can be pulled by Azure App Service and other container platforms.

8.1 Create ACR through Azure Portal

  1. Open Azure Portal.

  2. Search for Container registries.

  3. Select Create.

  4. Configure the Basics tab.

Example configuration:

Setting Value
Resource Group rg-docker
Registry Name jntechportfolioacr
Region Central India
SKU Select a suitable tier

8.2 Configure permissions

For this implementation, use:

RBAC Registry Permissions

This permits the standard ACR roles:

  • AcrPush – Push images into ACR.

  • AcrPull – Pull images from ACR.

We use the standard RBAC model rather than ABAC repository conditions for this App Service integration.

8.3 Create registry

  1. Select Review + create.

  2. Verify the settings.

  3. Select Create.

  4. Wait for successful provisioning.

  5. Open the registry.

8.4 Verify registry details

From the Overview page, copy the Login Server.

Our registry:

Registry Name:
jntechportfolioacr

Login Server:
jntechportfolioacr.azurecr.io

9. Authenticate Docker with Azure

Install Azure CLI if it is not already available.

Check:

az --version

Verify your Azure subscription:

az account show --output table

Assign AcrPush to the identity you use for manual image uploads.

In Azure Portal:

  1. Open ACR.

  2. Select Access control (IAM).

  3. Add role assignment.

  4. Select AcrPush.

  5. Choose your developer identity.

  6. Review and assign.

Authenticate:

az acr login --name jntechportfolioacr

Expected:

Login Succeeded

10. Tag the image for ACR

Generate the registry image reference:

$loginServer = "jntechportfolioacr.azurecr.io"

$acrImage = "$loginServer/syed-portfolio-api:$imageTag"

Tag the local image:

docker tag $localImage $acrImage

Verify image identity:

docker image inspect $localImage --format '{{.Id}}'

docker image inspect $acrImage --format '{{.Id}}'

Both image IDs should match.

Our image reference:

jntechportfolioacr.azurecr.io/syed-portfolio-api:sha-4c39725130e3

11. Push the image to ACR

Execute:

docker push $acrImage

Docker uploads the image layers and manifest to Azure Container Registry.

Expected output:

sha-4c39725130e3: digest: sha256:xxxxx size: xxxx

Verify through Azure Portal:

  1. Open ACR.

  2. Select Repositories.

  3. Open syed-portfolio-api.

  4. Verify the image tag.

  5. Inspect the image digest.

Our Docker image is now stored in Azure.

12. Create Azure App Service for Linux containers

Azure App Service provides a managed platform for hosting web applications and custom containers.

12.1 Create Web App

In Azure Portal:

  1. Search for App Services.

  2. Select Create.

  3. Select Web App.

Configure:

Setting Value
Resource Group rg-docker
App Name jntech-portfolio-api
Publish Container
Operating System Linux
Region Central India
App Service Plan asp-jntech-portfolio-linux
Pricing Plan F1 Free

F1 was used for this learning exercise. It is not recommended for an SLA-backed public production workload.

12.2 Configure the container

In the Container tab:

Setting Value
Image Source Azure Container Registry
Registry jntechportfolioacr
Image syed-portfolio-api
Tag sha-4c39725130e3
Port 8080

The port must match the port configured in your Dockerfile.

Create the Web App after reviewing the configuration.

13. Enable System-Assigned Managed Identity

Managed Identity allows Azure services to authenticate with one another without storing passwords or credentials.

Open:

App Service → Settings → Identity.

  1. Select System assigned.

  2. Change Status to On.

  3. Save.

  4. Confirm the operation.

Azure automatically creates a Microsoft Entra identity for the App Service.

14. Grant ACR pull permission

Your App Service must have permission to download the Docker image from ACR.

Open:

ACR → Access control (IAM) → Add role assignment.

Select:

AcrPull

Under Members:

Setting Value
Assign access to Managed identity
Managed identity App Service
Selected resource jntech-portfolio-api

Click Review + assign.

This grants the App Service identity read-only access to pull images from the registry.

15. Configure ACR authentication in App Service

Open:

App Service → Deployment → Deployment Center → Containers.

Edit the main container.

Configure:

Setting Value
Registry Azure Container Registry
Authentication Managed Identity
Identity System Assigned
Image syed-portfolio-api
Tag sha-4c39725130e3
Port 8080

Save the configuration.

The App Service now uses Managed Identity to authenticate with ACR.

Important: The registry must accept ARM-audience tokens for this Managed Identity image-pull configuration. If ACR returns an authentication or token-validation error, verify the registry's ARM audience authentication setting.

16. Configure Azure Key Vault access

Our API loads application secrets from Azure Key Vault during Production startup.

The existing Program.cs code uses:

builder.Configuration.AddAzureKeyVault(
    new Uri(vaultUri),
    new DefaultAzureCredential());

We need to grant the App Service Managed Identity access to the secrets.

16.1 Check permission model

Open:

Key Vault → Settings → Access configuration.

Our Key Vault uses:

Vault access policy

We will retain this permission model.

16.2 Create access policy

Navigate to:

Key Vault → Access policies → Create.

Select:

Permission category Permission
Key permissions None
Secret permissions Get, List
Certificate permissions None

16.3 Select principal

Search for:

jntech-portfolio-api

Select the System-Assigned Managed Identity.

Review and create the access policy.

16.4 Verify secret names

Navigate to:

Key Vault → Objects → Secrets.

Our application expects:

Key Vault secret ASP.NET Core configuration
ConnectionStrings--DefaultConnection ConnectionStrings
JwtSettings--Secret JwtSettings

Azure Key Vault uses double hyphens (--) to represent hierarchical configuration keys.

The Key Vault configuration provider converts these into colons (:).

The production connection string must point to Azure SQL Database, not local Windows SQL Server.

17. Configure App Service environment variables

Open:

App Service → Settings → Environment variables → App settings.

Add the following three settings.

Name Value Purpose
WEBSITES_PORT 8080 Container HTTP port
ASPNETCORE_ENVIRONMENT Production ASP.NET Core environment
KeyVault__VaultUri Actual Key Vault URI Key Vault configuration

Example:

WEBSITES_PORT = 8080

ASPNETCORE_ENVIRONMENT = Production

KeyVault__VaultUri =
https://your-keyvault-name.vault.azure.net/

The double underscore in KeyVault__VaultUri is converted to a colon by the ASP.NET Core configuration system.

Save the settings and allow App Service to restart.

Why is WEBSITES_PORT important?

Your Dockerfile uses:

ENV ASPNETCORE_HTTP_PORTS=8080

However, Azure App Service normally assumes that custom containers listen on port 80.

Therefore, we configure:

WEBSITES_PORT=8080

This tells App Service which internal HTTP port to route incoming requests to.

18. Configure Azure SQL Database connectivity

Our Portfolio API executes EF Core database migration during application startup.

The local Docker connection string is not suitable for Azure deployment.

Local configuration:

Server=host.docker.internal,1433;
Database=ServerDB_Bak_121026;

Production configuration must use the Azure SQL Database server and database.

Store the complete production connection string in:

ConnectionStrings--DefaultConnection

inside Azure Key Vault.

Also verify:

  • Azure SQL Database exists.

  • Database credentials are valid.

  • The application database user has necessary permissions.

  • Azure SQL firewall allows the App Service outbound traffic.

  • Any required virtual network and DNS configuration is correct.

For production networking, prefer private connectivity where the App Service plan and architecture support it.

The application must have sufficient permissions for its EF Core migrations. For production systems, a separately controlled database migration step is preferable to allowing every application instance to run migrations at startup.

19. Verify container deployment

Open:

App Service → Deployment Center → Containers.

Verify the main container:

Property Expected
Container main
Source Azure Container Registry
Image syed-portfolio-api
Tag sha-4c39725130e3
Port 8080

20. Enable application logging

Open:

App Service → Monitoring → App Service logs.

Configure:

Setting Value
Application logging File System
Quota 35 MB
Retention Period 1 day

Save the configuration.

Then open:

Monitoring → Log stream.

View logs through Kudu

  1. Open App Service.

  2. Navigate to Development Tools → Advanced Tools.

  3. Click Go.

  4. Open Kudu Dashboard.

  5. Select Browse Log Files.

  6. Open /home/LogFiles/.

Find the application log:

*_default_docker.log

Do not confuse it with:

*_default_scm_docker.log

The latter contains Kudu/SCM management container logs.

Our actual API log showed:

Starting DB Migration...
DB Migration completed

This confirmed the database migration succeeded.

21. Test the deployed API

Open:

Azure Portal → App Service → Overview.

Copy the actual Default Domain.

Test the API:

https://your-app-service-domain/api/articles/categories

In our implementation, this endpoint returned category data successfully.

Swagger may be disabled in Production, so use an actual public API endpoint to verify the deployment.

At this point, the manual Docker-to-Azure deployment is complete.

Part 2 – Automate deployment using Azure DevOps CI/CD

Manual Docker deployment is useful for initial learning, but repeatedly building, tagging and pushing images manually is not ideal for ongoing development.

We will automate this process using Azure DevOps YAML.

22. Understand the CI/CD architecture

Developer
    |
    ▼
Git Push (Dev Branch)
    |
    ▼
Azure DevOps Pipeline
    |
    ├── Stage 1: BuildAndPush
    |       |
    |       ├── Checkout source
    |       ├── Verify Docker
    |       ├── Build Docker image
    |       └── Push image to ACR
    |
    └── Stage 2: Deploy
            |
            ├── Update App Service image
            └── Test API endpoint
                    |
                    ▼
               Azure App Service

A push to the Dev branch triggers the pipeline.

23. Create Azure DevOps service connections

We created two service connections.

23.1 ACR Docker Registry service connection

In Azure DevOps:

  1. Open Project Settings.

  2. Select Service connections.

  3. Select New service connection.

  4. Choose Docker Registry.

  5. Select Azure Container Registry.

  6. Select Workload Identity Federation authentication.

  7. Choose your subscription and ACR.

  8. Save.

Service connection name:

sc-acr-jntechportfolioacr

The identity behind this connection must have AcrPush permission on your ACR.

23.2 Azure Resource Manager service connection

Follow:

  1. Project Settings → Service connections.

  2. Select New service connection.

  3. Select Azure Resource Manager.

  4. Configure Workload Identity Federation.

  5. Select your subscription and deployment scope.

  6. Save.

Service connection name:

sc-azure-rg-docker

Assign appropriate permissions to update the target App Service. Website Contributor scoped to the application is a suitable starting point.

Understand the identity separation

Identity Permission Purpose
ACR pipeline identity AcrPush Push images into ACR
Azure deployment identity Website Contributor Update App Service
App Service Managed Identity AcrPull Download images from ACR
App Service Managed Identity Key Vault Get, List Read application secrets

Use Workload Identity Federation wherever supported instead of storing long-lived client secrets.

24. Prepare Azure DevOps self-hosted agent

We used a self-hosted Windows agent.

Our YAML uses:

pool:
  name: Default

The self-hosted agent must have:

  • Azure DevOps agent service running.

  • Docker Desktop installed.

  • Docker Engine running.

  • Docker CLI available to the agent.

  • Docker Buildx available.

  • Network access to Azure services.

If a pipeline reports:

Pipeline does not have permissions to use the referenced pool(s) Default

Authorize the specific pipeline to use the agent pool through its pipeline permissions.

Do not enable open access to all pipelines unnecessarily.

25. Create azure-pipelines.yml

Create this file in the solution root directory:

azure-pipelines.yml

Use the following YAML.

Complete Azure DevOps pipeline

# Trigger pipeline whenever code is pushed to Dev
trigger:
  branches:
    include:
      - Dev

# Disable automatic PR deployment
pr: none

# Pipeline variables
variables:

  # Service connections
  acrServiceConnection: 'sc-acr-jntechportfolioacr'
  azureServiceConnection: 'sc-azure-rg-docker'

  # Container Registry
  registryLoginServer: 'jntechportfolioacr.azurecr.io'
  imageRepository: 'syed-portfolio-api'

  # Docker
  dockerfilePath: '$(Build.SourcesDirectory)/Dockerfile'
  buildContext: '$(Build.SourcesDirectory)'

  # App Service
  appName: 'jntech-portfolio-api'
  appUrl: 'https://YOUR-ACTUAL-APP-SERVICE-DOMAIN'

  # Agent pool
  agentPool: 'Default'

stages:

# ====================================
# STAGE 1: BUILD AND PUSH
# ====================================

- stage: BuildAndPush
  displayName: 'Build and Push Docker Image'

  jobs:
  - job: Build
    displayName: 'Build Portfolio API Image'

    pool:
      name: $(agentPool)

    steps:

    # Checkout source code
    - checkout: self
      clean: true

    # Verify Docker Engine and Buildx
    - task: PowerShell@2
      displayName: 'Verify Docker Engine'
      inputs:
        targetType: inline
        script: |

          docker version

          if ($LASTEXITCODE -ne 0) {
              throw "Docker Engine is unavailable."
          }

          docker buildx version

          if ($LASTEXITCODE -ne 0) {
              throw "Docker Buildx is unavailable."
          }

    # Build and push image
    - task: Docker@2
      displayName: 'Build and Push Image to ACR'
      inputs:
        command: 'buildAndPush'
        containerRegistry: '$(acrServiceConnection)'
        repository: '$(imageRepository)'
        Dockerfile: '$(dockerfilePath)'
        buildContext: '$(buildContext)'

        tags: |
          $(Build.BuildId)
          $(Build.SourceVersion)


# ====================================
# STAGE 2: DEPLOY
# ====================================

- stage: Deploy
  displayName: 'Deploy to Azure App Service'

  dependsOn: BuildAndPush
  condition: succeeded()

  jobs:
  - job: DeployAppService
    displayName: 'Deploy Portfolio API'

    pool:
      name: $(agentPool)

    steps:

    # Deploy image to App Service
    - task: AzureWebAppContainer@1
      displayName: 'Deploy Container'
      inputs:
        azureSubscription: '$(azureServiceConnection)'
        appName: '$(appName)'
        containers: '$(registryLoginServer)/$(imageRepository):$(Build.BuildId)'

    # Verify API
    - task: PowerShell@2
      displayName: 'API Smoke Test'
      inputs:
        targetType: inline
        script: |

          $apiUrl = "$(appUrl)/api/articles/categories"

          $maxAttempts = 18
          $success = $false

          for ($i = 1; $i -le $maxAttempts; $i++) {

              try {

                  Write-Host "Testing API: $apiUrl"

                  $response = Invoke-WebRequest `
                      -Uri $apiUrl `
                      -Method Get `
                      -UseBasicParsing `
                      -TimeoutSec 30

                  if ($response.StatusCode -ge 200 -and
                      $response.StatusCode -lt 300) {

                      Write-Host "API deployment successful!"
                      Write-Host "HTTP Status: $($response.StatusCode)"

                      $success = $true
                      break
                  }
              }
              catch {

                  Write-Warning "Attempt $i failed: $($_.Exception.Message)"
              }

              Start-Sleep -Seconds 10
          }

          if (-not $success) {
              throw "API smoke test failed."
          }

Replace appUrl with the actual App Service default domain verified in your Azure Portal.

The service connection names must match the names created in Azure DevOps.

The Buildx pre-check assumes your self-hosted agent has the Docker Buildx component installed.

The Docker@2 buildAndPush task builds and pushes the two image tags in one operation. Additional Docker build arguments are not applied when using this convenience command.

26. Understand the pipeline stages

Stage 1: BuildAndPush

The Docker@2 task performs:

command: 'buildAndPush'

It builds the Docker image and pushes it to ACR.

We use two tags:

$(Build.BuildId)
$(Build.SourceVersion)

Example:

jntechportfolioacr.azurecr.io/syed-portfolio-api:125

jntechportfolioacr.azurecr.io/syed-portfolio-api:<full-git-commit-sha>

The first tag identifies the Azure DevOps build.

The second tag identifies the Git source revision.

Stage 2: Deploy

The AzureWebAppContainer task updates the App Service container image.

- task: AzureWebAppContainer@1
  inputs:
    azureSubscription: 'sc-azure-rg-docker'
    appName: 'jntech-portfolio-api'
    containers: 'jntechportfolioacr.azurecr.io/syed-portfolio-api:125'

After the deployment, the PowerShell task calls:

/api/articles/categories

It retries the request to allow time for container startup.

If the API returns a successful HTTP status, the deployment smoke test passes.

27. Create the pipeline in Azure DevOps

After saving azure-pipelines.yml:

  1. Open Azure DevOps.

  2. Open your Portfolio project.

  3. Select Pipelines.

  4. Select New pipeline.

  5. Choose Azure Repos Git.

  6. Select your Git repository.

  7. Choose Existing Azure Pipelines YAML file.

  8. Select the Dev branch.

  9. Select /azure-pipelines.yml.

  10. Click Continue.

  11. Review the YAML.

  12. Create or save the pipeline.

28. Run the pipeline

Before running, verify that:

  • Both service connections exist.

  • The service connections are authorized for the pipeline.

  • The self-hosted agent is online.

  • Docker Desktop is running.

  • Docker Buildx is installed.

  • The agent pool is authorized for the pipeline.

Select Run pipeline.

Expected execution:

BuildAndPush
     |
     ├── Checkout source code
     |
     ├── Verify Docker Engine
     |
     ├── Verify Buildx
     |
     ├── Build Docker Image
     |
     └── Push to ACR
              |
              ▼
         Deploy Stage
              |
              ├── Update App Service
              |
              └── API Smoke Test
                       |
                       ▼
                  Deployment Success

29. Troubleshooting

During this implementation, we encountered several common issues.

Issue 1: Azure Key Vault authentication failed locally

Error:

DefaultAzureCredential failed to retrieve a token

Reason:

The container ran in Production mode and attempted to authenticate with Azure Key Vault, but no local Azure identity was available.

Solution for local testing:

-e ASPNETCORE_ENVIRONMENT=Development

Production continues to use Azure Managed Identity.

Issue 2: SQL Server connection failed inside Docker

Error:

A network-related or instance-specific error occurred
while establishing a connection to SQL Server.

Reason:

The connection string used:

Server=.

Inside Docker, this refers to the container, not the Windows SQL Server.

Solution:

Server=host.docker.internal,1433

We also enabled TCP/IP, configured port 1433 and created a dedicated SQL Server login.

Issue 3: Docker container exited with code 139

The container initially failed during application startup.

Inspection of the logs identified the Key Vault authentication issue first and the SQL Server connectivity problem afterward.

Always inspect:

docker logs syed-portfolio-api-local

Do not assume every container exit is caused by Docker itself. The application can terminate because of an unhandled startup exception.

Issue 4: Azure App Service could not authenticate with ACR

Error:

The selected managed identity does not have pull permissions
over the selected Azure Container Registry.

Reason:

App Service did not have the expected registry permission.

Solution:

  1. Enable System-Assigned Managed Identity.

  2. Assign AcrPull on ACR.

  3. Select Managed Identity authentication in Deployment Center.

  4. Select System Assigned identity.

  5. Save the container configuration.

Also verify that ACR accepts ARM-audience authentication tokens.

Issue 5: App Service could not connect to the API container

Reason:

The application listened on port 8080, but App Service initially expected port 80.

Solution:

Configure:

WEBSITES_PORT=8080

Also configure port 8080 in the Deployment Center container settings.

Issue 6: Deployment Center logs were empty

We initially inspected the SCM Docker log.

The file:

_default_scm_docker.log

contains Kudu/SCM container logs, not the Portfolio API application logs.

The correct file:

_default_docker.log

In our implementation, this log confirmed:

Starting DB Migration...
DB Migration completed

Issue 7: Azure DevOps pipeline could not use the Default agent pool

Error:

Pipeline does not have permissions to use
the referenced pool(s) Default.

Reason:

The pipeline was not authorized to use the self-hosted agent pool.

Solution:

Authorize the specific pipeline to use the Default agent pool through its pipeline permissions.

Avoid granting unnecessary access to every pipeline.

Issue 8: Docker image build failed with input/output error

Error:

failed to perform sync:
input/output error

This happened while Docker Desktop was saving a downloaded .NET SDK image layer.

Troubleshooting:

  1. Restart Docker Desktop.

  2. Verify Docker Engine is running.

  3. Check Docker storage:

docker system df
  1. Check available Windows disk space:

Get-PSDrive C
  1. Test pulling the base image:

docker pull mcr.microsoft.com/dotnet/sdk:8.0
  1. Verify Docker Buildx:

docker buildx version
  1. Retry the pipeline after Docker storage is healthy.

Do not delete Docker volumes or run destructive cleanup commands without reviewing what they contain.

Issue 9: Docker legacy builder deprecated warning

Warning:

DEPRECATED: The legacy builder is deprecated.

Install the buildx component to build images with BuildKit.

Solution:

Ensure Docker Desktop has the Buildx component installed and available to the self-hosted agent.

Modern Docker Desktop uses BuildKit for Linux image builds. Buildx provides the Docker CLI integration required by BuildKit.

30. Future improvements

Once the deployment workflow is established, consider these production improvements.

Area Recommended improvement
App Service Plan Use an appropriate paid production tier
.NET Framework Upgrade to a supported .NET LTS version
Image security Add vulnerability scanning
Image versioning Retain immutable build identifiers
Database migration Use a controlled release migration step
Monitoring Integrate Application Insights and Azure Monitor
CI/CD Add approval gates for production
Rollback Automate rollback to a previous image
Security Continue using Managed Identity and Key Vault
Networking Consider private endpoints and controlled outbound connectivity

For production, avoid allowing every running application instance to execute schema migrations concurrently. A dedicated migration stage is safer.

The F1 plan used in this tutorial is suitable for learning and basic verification, not an SLA-backed production workload.

31. Final deployment architecture

Our final deployment architecture is:

                  Git Repository
                        |
                        |
                   Push to Dev
                        |
                        ▼
                Azure DevOps Pipeline
                        |
             ┌──────────┴──────────┐
             |                     |
             ▼                     ▼
         Build Stage           Test Stage
             |                     |
             ▼                     |
        Docker Build               |
             |                     |
             ▼                     |
         Image Scan                |
             |                     |
             ▼                     |
          ACR Push                 |
             |                     |
             └──────────┬──────────┘
                        |
                        ▼
                Azure Container
                    Registry
                        |
                        |
                  Managed Identity
                        |
                        ▼
                 Azure App Service
                        |
                        ▼
              ASP.NET Core Portfolio API
                        |
                ┌───────┴────────┐
                |                |
                ▼                ▼
            Azure SQL        Azure Key Vault
            Database          Secrets

Conclusion

We have learned how to containerize an existing ASP.NET Core Web API and deploy it to Azure App Service using Docker and Azure Container Registry.

We also implemented an Azure DevOps YAML pipeline that automates Docker image creation, registry publishing and application deployment.

The important concepts covered include:

  • Multi-stage Docker builds.

  • Docker images and containers.

  • Docker networking and port mapping.

  • Azure Container Registry.

  • Docker image tagging and versioning.

  • Azure App Service custom containers.

  • Managed Identity.

  • Azure Key Vault.

  • Azure SQL Database connectivity.

  • Azure DevOps service connections.

  • Self-hosted Azure DevOps agents.

  • Multi-stage YAML pipelines.

  • Automated deployment and API smoke testing.

The same deployment approach can be applied to other ASP.NET Core Web API projects and containerized applications hosted on Azure.

Official Documentation

  1. Configure a Custom Container in Azure App Service

  2. Azure Container Registry Documentation

  3. Azure Container Registry Built-in Roles

  4. Docker Buildx Documentation

  5. Azure DevOps Docker@2 Task

  6. AzureWebAppContainer@1 Task

  7. Azure DevOps Service Connections

  8. ASP.NET Core Configuration

← Back to Articles