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:
-
Creating a Dockerfile for an ASP.NET Core Web API.
-
Creating a Docker image using a multi-stage build.
-
Running and testing the container locally.
-
Creating Azure Container Registry.
-
Pushing the Docker image to ACR.
-
Creating Azure App Service for Linux containers.
-
Configuring Managed Identity and Key Vault.
-
Deploying the container to Azure App Service.
-
Implementing Azure DevOps CI/CD using YAML.
-
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
-
Open Azure Portal.
-
Search for Container registries.
-
Select Create.
-
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
-
Select Review + create.
-
Verify the settings.
-
Select Create.
-
Wait for successful provisioning.
-
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:
-
Open ACR.
-
Select Access control (IAM).
-
Add role assignment.
-
Select AcrPush.
-
Choose your developer identity.
-
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:
-
Open ACR.
-
Select Repositories.
-
Open
syed-portfolio-api. -
Verify the image tag.
-
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:
-
Search for App Services.
-
Select Create.
-
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.
-
Select System assigned.
-
Change Status to On.
-
Save.
-
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
-
Open App Service.
-
Navigate to Development Tools → Advanced Tools.
-
Click Go.
-
Open Kudu Dashboard.
-
Select Browse Log Files.
-
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:
-
Open Project Settings.
-
Select Service connections.
-
Select New service connection.
-
Choose Docker Registry.
-
Select Azure Container Registry.
-
Select Workload Identity Federation authentication.
-
Choose your subscription and ACR.
-
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:
-
Project Settings → Service connections.
-
Select New service connection.
-
Select Azure Resource Manager.
-
Configure Workload Identity Federation.
-
Select your subscription and deployment scope.
-
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:
-
Open Azure DevOps.
-
Open your Portfolio project.
-
Select Pipelines.
-
Select New pipeline.
-
Choose Azure Repos Git.
-
Select your Git repository.
-
Choose Existing Azure Pipelines YAML file.
-
Select the Dev branch.
-
Select
/azure-pipelines.yml. -
Click Continue.
-
Review the YAML.
-
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:
-
Enable System-Assigned Managed Identity.
-
Assign AcrPull on ACR.
-
Select Managed Identity authentication in Deployment Center.
-
Select System Assigned identity.
-
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:
-
Restart Docker Desktop.
-
Verify Docker Engine is running.
-
Check Docker storage:
docker system df
-
Check available Windows disk space:
Get-PSDrive C
-
Test pulling the base image:
docker pull mcr.microsoft.com/dotnet/sdk:8.0
-
Verify Docker Buildx:
docker buildx version
-
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.