Skip to main content

Deploy a custom container

In this lab, you package a small web app as a container image, push it to Azure Container Registry, and run it on Azure App Service with Web App for Containers. A custom container lets you bring your own runtime, system packages, and dependencies while App Service handles TLS, scaling, and patching of the host.

The app pulls its image from your registry using a managed identity granted the AcrPull role - so App Service authenticates to the registry with an Azure identity instead of admin username and password credentials.

You'll build the same result three ways so you can pick the workflow that fits you:

  • Azure Developer CLI (azd) - an opinionated, repeatable workflow that provisions the registry and app, then builds and pushes your image in one step.
  • Azure CLI (az) - explicit commands that create each resource, build the image, and wire up managed-identity pull.
  • Azure portal - a visual, click-through experience.

You can follow the lab in your language of choice: .NET, Node.js, Python, Java, or PHP. Each path pins its base image to a specific tag so builds stay reproducible.

App Service Labs complements Microsoft Learn

This lab is a hands-on, end-to-end walkthrough. For reference depth on any concept, follow the "Learn more" links to the official Microsoft Learn quickstarts.

Estimated time: 20-30 minutes

What you'll build

A Linux web app running on an App Service plan that pulls a container image from Azure Container Registry. App Service uses the app's system-assigned managed identity (holding the AcrPull role) to authenticate to the registry, then serves the container at a default https://<app-name>.azurewebsites.net hostname that returns HTTP 200.

Prerequisites

Before you begin, you will need an Azure subscription with Owner permissions and a GitHub account.

In addition, you will need the following tools installed on your local machine:

Setup Azure CLI

Start by logging into Azure by run the following command and follow the prompts:

az login --use-device-code
tip

You can log into a different tenant by passing in the --tenant flag to specify your tenant domain or tenant ID.

Choose a region and low-cost tier

This lab uses the East US region and the B1 (Basic) pricing tier, a low-cost option that's ideal for learning. Custom containers require a Basic tier or higher - the Free (F1) tier doesn't support them. You can change the region to one near you.

Containerize the app

Each language below packages a tiny web server that returns Hello from a custom container on Azure App Service! and listens on port 80. Pick your language, create the files, then build the image in a later step.

App Service for Linux containers routes public traffic to the port your container listens on. This lab has every container listen on port 80 and sets the WEBSITES_PORT app setting to 80 so the mapping is explicit.

Choose your path
Set these once - the sample files and every deployment step below follow your choice.
Language
Deploy with

Create an ASP.NET Core app:

mkdir custom-container-app && cd custom-container-app
dotnet new web -n MyApp

Make the app listen on port 80. In MyApp/Program.cs, ensure the app responds on the root path:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => Results.Content(
"<h1>Hello from a custom container on Azure App Service!</h1>", "text/html"));
app.Run();

Create Dockerfile in the custom-container-app folder. This multi-stage build compiles with the SDK image and runs on the smaller ASP.NET runtime image:

# Build stage - pinned to a specific tag, never :latest
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY MyApp/. ./MyApp/
RUN dotnet publish MyApp -c Release -o /app

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:80
EXPOSE 80
ENTRYPOINT ["dotnet", "MyApp.dll"]
Pin to a digest for full reproducibility

Pinning to a tag (for example, node:22-alpine) is required by this lab - never use :latest. For the strongest guarantee, pin to an immutable digest, for example node:22-alpine@sha256:..., so the base image can never change underneath you.

Deploy to App Service

Choose a deployment mechanism. All three create a Basic B1 Linux App Service plan, an Azure Container Registry with the admin user disabled, and a web app that pulls the image using its system-assigned managed identity.

The Azure Developer CLI provisions the registry and app with Bicep, then a postprovision hook builds your image with az acr build, pushes it to the registry, and points the app at it. Add these files alongside your Dockerfile.

azure.yaml

# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: asl-custom-container
hooks:
postprovision:
posix:
shell: sh
# Build the image in the cloud and point the app at it after provisioning.
# The variables come from the Bicep outputs (azd exposes them as env vars).
run: |
az acr build --registry "$ACR_NAME" --image customwebapp:v1 .
az webapp config container set \
--name "$WEB_APP_NAME" --resource-group "$AZURE_RESOURCE_GROUP" \
--container-image-name "$ACR_LOGIN_SERVER/customwebapp:v1" \
--container-registry-url "https://$ACR_LOGIN_SERVER"
az webapp restart --name "$WEB_APP_NAME" --resource-group "$AZURE_RESOURCE_GROUP"
Why a hook instead of a service

azd's built-in code deploy (the services block) publishes app code, not container images, to App Service. Building the image in a postprovision hook keeps everything in one azd up and pulls with managed identity - no admin credentials.

infra/main.bicep

targetScope = 'subscription'

@description('Name of the azd environment; used to derive resource names.')
param environmentName string

@description('Azure region for all resources.')
param location string

@description('Resource group to create for this environment.')
param resourceGroupName string

resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
name: resourceGroupName
location: location
}

module resources 'resources.bicep' = {
name: 'resources'
scope: rg
params: {
location: location
environmentName: environmentName
}
}

// azd surfaces these outputs as environment variables the postprovision hook reads.
output ACR_NAME string = resources.outputs.acrName
output ACR_LOGIN_SERVER string = resources.outputs.registryEndpoint
output WEB_APP_NAME string = resources.outputs.webAppName
output WEB_URI string = resources.outputs.webUri

infra/resources.bicep

@description('Azure region for all resources.')
param location string

@description('azd environment name used to derive globally unique names.')
param environmentName string

var suffix = uniqueString(subscription().id, resourceGroup().id, environmentName)
var acrName = toLower('acr${suffix}')
var planName = 'plan-${suffix}'
var webName = 'app-${suffix}'
// Built-in AcrPull role definition ID.
var acrPullRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')

resource acr 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = {
name: acrName
location: location
sku: { name: 'Basic' }
properties: {
adminUserEnabled: false // pull uses managed identity, not admin creds
}
}

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: planName
location: location
sku: { name: 'B1', tier: 'Basic' }
kind: 'linux'
properties: { reserved: true } // required for Linux plans
}

resource web 'Microsoft.Web/sites@2023-12-01' = {
name: webName
location: location
kind: 'app,linux,container'
identity: { type: 'SystemAssigned' } // system-assigned managed identity for ACR pull
properties: {
serverFarmId: plan.id
httpsOnly: true
siteConfig: {
// The hook sets the real image after provisioning; this is just a placeholder.
linuxFxVersion: 'DOCKER|${acr.properties.loginServer}/${webName}:starter'
acrUseManagedIdentityCreds: true // pull from ACR with the managed identity
appSettings: [
{ name: 'WEBSITES_PORT', value: '80' }
]
}
}
}

// Grant the web app's managed identity permission to pull from the registry.
resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(acr.id, web.id, acrPullRoleId)
scope: acr
properties: {
roleDefinitionId: acrPullRoleId
principalId: web.identity.principalId
principalType: 'ServicePrincipal'
}
}

output acrName string = acr.name
output registryEndpoint string = acr.properties.loginServer
output webAppName string = web.name
output webUri string = 'https://${web.properties.defaultHostName}'

infra/main.parameters.json

{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"environmentName": { "value": "${AZURE_ENV_NAME}" },
"location": { "value": "${AZURE_LOCATION}" },
"resourceGroupName": { "value": "${AZURE_RESOURCE_GROUP}" }
}
}

Sign in, create an environment with a unique suffix, then provision and deploy:

azd auth login

SUFFIX=$(openssl rand -hex 3) # 6 lowercase hex chars
azd env new "asl-cc-${SUFFIX}" --location eastus
azd env set AZURE_RESOURCE_GROUP "rg-customcontainer-${SUFFIX}"

azd up

azd up provisions the registry, plan, and app, then runs the hook to build the image and point the app at it. When it finishes, azd prints a success message and the WEB_URI output:

SUCCESS: Your up workflow to provision and deploy to Azure completed in 2 minutes 16 seconds.

Get the app URL any time with:

azd env get-value WEB_URI

Verify your app is running

Open the app's URL in a browser, or test it from the command line and confirm you get an HTTP 200 response:

curl -I https://<your-app-name>.azurewebsites.net

Expected output (validated during authoring):

HTTP/1.1 200 OK
Content-Type: text/html

You should see the page served by your container:

The running container in the browser

First request can be slow

The first request after a deployment pulls the image and starts the container, which can take a minute or two. If you see a "starting" or 503 page, wait a moment and refresh. Check container startup logs any time with az webapp log tail --resource-group "$RG" --name "$APP".

Windows containers

This lab uses Linux containers, the mainstream path for App Service. App Service also supports Windows containers for apps that need the Windows base image - for example, older .NET Framework apps. Windows containers require a Windows container plan (az appservice plan create --hyper-v --sku P1V3) and a Windows base image such as mcr.microsoft.com/dotnet/framework/aspnet:4.8-windowsservercore-ltsc2022. The registry, managed identity, and AcrPull steps are the same. See Run a custom Windows container.

Clean up resources

To avoid ongoing charges, delete the resources when you're done. Deleting the resource group removes the web app, the App Service plan, and the container registry.

azd down --purge --force

Summary

In this lab, you packaged a web app as a container image and ran it on Azure App Service, confirming it returns an HTTP 200 response. You learned how to:

  • Write a Dockerfile for .NET, Node.js, Python, Java, or PHP, pinned to a specific base image tag instead of :latest.
  • Build and push an image to Azure Container Registry with az acr build or local Docker.
  • Create a Web App for Containers on a Basic B1 Linux plan three ways - with azd, the Azure CLI, and the Azure portal.
  • Pull the image with a system-assigned managed identity granted the AcrPull role, avoiding stored admin credentials.
  • Clean up resources to avoid charges.

Troubleshooting

  • The app shows a default "welcome" page, not your container. App Service may still be pulling and starting the image. Wait a minute, then refresh. Tail the logs with az webapp log tail --resource-group "$RG" --name "$APP".
  • 504, 503, or a blank page. The container likely isn't listening on the expected port. Confirm the app listens on port 80 and that the WEBSITES_PORT app setting is 80.
  • Image pull fails with an authentication or 403 error. The managed identity may lack the AcrPull role, or acrUseManagedIdentityCreds isn't set. Re-run the role assignment and az webapp config set --generic-configurations '{"acrUseManagedIdentityCreds": true}', then restart the app. Role assignments can take a minute to propagate.
  • az webapp config container set warns it couldn't retrieve credentials. Expected when the registry admin user is disabled. Managed identity handles the pull; the warning is safe to ignore.
  • az acr build fails or is slow. Confirm your Dockerfile builds locally with docker build .. Very large build contexts upload slowly - add a .dockerignore to exclude files you don't need in the image.

Learn more