• English icon
  • German icon
Explainer

Why your container returns a 503 on Azure App Service

· Series: Containers on Azure

azureazure-app-servicedockercontainersdevops

A container runs fine on a laptop. The same image goes to Azure App Service, starts, and App Service waits. It sends requests to port 80 and to port 8080, gets no answer because the app listens on 8000, gives up, tries again, and the users get a 503.

This explainer walks through what App Service does around a custom container: how it pulls the image, how traffic reaches the right port, what survives a restart, how settings get in without a rebuild, and where to look when something goes wrong. One idea connects all of it: the image never changes; everything else plugs in from the outside.

Key points

  • App Service only finds ports 80 and 8080 on its own. Any other port needs the app setting WEBSITES_PORT, and the app must listen on 0.0.0.0, not localhost.
  • Pull with a managed identity. Give the web app's identity the AcrPull role on the registry; no password is stored anywhere.
  • The container's file system is temporary. Only /home survives restarts, and only when persistent storage is on (the app setting WEBSITES_ENABLE_APP_SERVICE_STORAGE set to true).
  • Configuration comes from the outside. App settings arrive as environment variables, slot settings stay with their slot, and secrets can come from Key Vault references, so one image serves every environment.

Pulling the image

When you create a web app for containers, you choose where the image comes from. Azure Container Registry is the recommended source. Other registries work too: Docker Hub, GitHub Container Registry, or any registry that speaks the Docker Registry API.

A private registry needs a login, and there are two ways to provide it:

Managed identityAdmin user
What it isan identity in Microsoft Entra ID that belongs to the web appa username and password stored in the registry
Accessthe AcrPull role on the registrythe admin user has to be enabled on the registry
Secretsnone stored anywherethe password sits in the app's configuration
Rotationnothing to rotatemanual

A system-assigned identity lives and dies with the app. A user-assigned identity is its own resource, and several apps can share it.

az webapp create -g rg -p plan -n docproc \
--container-image-name myacr.azurecr.io/docprocessor:v1
az webapp identity assign -g rg -n docproc
az role assignment create --assignee $PRINCIPAL_ID \
--role AcrPull --scope $ACR_ID
az webapp config set -g rg -n docproc \
--acr-use-identity true --acr-identity [system]

When App Service pulls: on the first deployment it pulls every layer; on a restart only the layers that changed; when you scale out, every new instance pulls the image.

New versions: point the app to a new tag, and App Service restarts and pulls it. If you push a new image under the same tag, App Service doesn't notice. Restart the app, or turn on continuous deployment: az webapp deployment container config --enable-cd true returns a webhook URL, and once you add it as a push webhook in the registry, every push restarts the app for you.

Traffic and the port

Requests from the internet arrive at the App Service front ends. The front ends end the HTTPS connection and forward plain HTTP into the container, to a single port.

App Service looks for that port on its own: 80 or 8080. ASP.NET Core container images listen on 8080 since .NET 8, so they work without any setting. Any other port, such as gunicorn on 8000 or Express on 3000, needs the app setting WEBSITES_PORT. App Service reads this setting itself; it is not passed into the container as an environment variable.

At startup, App Service sends warm-up requests and waits for any answer, even an error. If nothing answers within the start time limit, the start fails and App Service tries again, which shows up as 503 errors.

One more trap: the app must listen on 0.0.0.0, on all network interfaces. If it only listens on localhost, requests from outside the container never reach it.

A startup command replaces the CMD instruction of the Dockerfile, while the ENTRYPOINT stays:

az webapp config appsettings set -g rg -n docproc \
--settings WEBSITES_PORT=8000
az webapp config set -g rg -n docproc \
--startup-file "gunicorn --bind=0.0.0.0:8000 app:app"

Files and restarts

By default, a file the app writes is gone after the next restart. The container's file system is temporary: when the app restarts or moves to another machine, everything written inside the container is lost.

For files that must survive, App Service can mount persistent storage at /home. For custom Linux containers it is off by default; set WEBSITES_ENABLE_APP_SERVICE_STORAGE to true. Then files in /home survive restarts, every instance of a scaled-out app sees the same files, and the space counts against the storage quota of the App Service plan.

/home is a network share. That is fine for uploads and output files, but not for a file-based database like SQLite, which needs file locks the share can't give. For large data, mount Azure Storage or use a real database.

Settings from the outside

Rebuilding the image for development, staging and production would defeat the point of a container. App settings are name and value pairs that App Service injects as environment variables when the container starts. The image stays the same; only the variables change.

import os
log_level = os.environ.get("LOG_LEVEL", "WARNING")
storage = os.environ["STORAGE_ACCOUNT_NAME"]
api_key = os.environ["API_KEY"]
  • Encrypted at rest. All app settings are stored encrypted.
  • Names: letters, numbers and underscores. A nested .NET key like ConnectionStrings:Default is written as ConnectionStrings__Default.
  • Connection strings get a prefix for the database type in front of the name, for example SQLAZURECONNSTR_Default for Azure SQL. That helps .NET apps; for Python or Node, plain app settings are simpler.
  • Slot settings: with deployment slots, mark the values that belong to an environment as slot settings. When you swap staging into production, the code moves, but slot settings stay with their slot.

For secrets, use a Key Vault reference as the setting's value. App Service fetches the secret with the app's managed identity, and the code still just reads an environment variable:

API_KEY = @Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/api-key)

After a rotation, the new value arrives within 24 hours, or right away when the app restarts.

Warm and healthy

Without traffic, App Service can unload an idle app after about 20 minutes. The next request then waits for a cold start, and a large image makes that slower. Always On prevents this: App Service sends a request to the app every five minutes, so it never goes idle. It needs the Basic tier or higher.

Health checks go one step further. You give App Service a path, like /health. Every minute, it calls that path on every instance and expects a status code between 200 and 299.

What happensWhen
The instance leaves the load balancerafter ten failed checks in a row (by default)
It returns to the load balanceras soon as it answers healthy again
It is replacedafter an hour of being unhealthy

This only helps with two or more instances: with a single instance, there is nowhere else to send the traffic. A good health endpoint checks what the app really needs and returns an error when it can't reach it:

@app.route("/health")
def health():
try:
db.execute("SELECT 1")
except Exception:
return {"status": "unhealthy"}, 503
return {"status": "healthy"}, 200
az webapp config set -g rg -n docproc --always-on true
az webapp config set -g rg -n docproc \
--generic-configurations '{"healthCheckPath": "/health"}'

Logs and where to look

App Service captures everything the container writes to standard output and standard error. Turn on container logging, and these lines are saved under /home/LogFiles. The log stream shows them live, from every instance, each line with its instance ID:

az webapp log config -g rg -n docproc \
--docker-container-logging filesystem
az webapp log tail -g rg -n docproc

For a look behind the scenes, the Kudu site (also called the SCM site, <app>.scm.azurewebsites.net) has an environment page that lists every variable the app receives. Kudu runs in its own container, so it can't see the processes inside yours. For that, use SSH: the image has to bring an SSH server on port 2222 with the root password App Service expects, and it is reachable only through Azure, not from the internet. For longer retention, send the logs to a Log Analytics workspace and query them with KQL.

SymptomWhere to look
The container never startsthe port (WEBSITES_PORT, 0.0.0.0) and the log stream
A setting seems to be missingthe environment page in Kudu
The new image doesn't show upthe tag, or restart the app

Further reading

Video transcript

Your container runs fine on your laptop. You deploy the same image to Azure App Service. It starts, and App Service waits. It knocks on port 80, then on port 8080. No answer, because your app listens on port 8000. App Service gives up, tries again, and your users get a 503.

In this video, you will see how App Service runs your container: how it pulls the image, how traffic finds the right port, what survives a restart, how settings get in without a rebuild, and how to find out what went wrong. One idea connects all of it. The image never changes. Everything else plugs in from the outside.

Start with the pull. Before App Service can run anything, it has to get your image. You choose where the image comes from. Azure Container Registry is the recommended source. Other registries work too: Docker Hub, GitHub Container Registry, or any registry that speaks the Docker Registry API. A private registry needs a login. The recommended way is a managed identity: an identity in Microsoft Entra ID that belongs to your web app. You give it the AcrPull role on the registry, and App Service pulls with it. No password is stored anywhere. A system-assigned identity lives and dies with the app. A user-assigned identity is its own resource, and several apps can share it. The other way is the registry's admin user, with a username and a password. That is fine for a quick test, but the password sits in your app's configuration, and you have to rotate it yourself. In the Azure CLI, az webapp create takes the full image name, with a tag. Then you turn on the identity, give it AcrPull on the registry, and tell the app to pull with that identity. So when does App Service pull? On the first deployment, it pulls every layer. On a restart, only the layers that changed. And when you scale out, every new instance pulls the image. To release a new version, point the app to a new tag. App Service restarts and pulls it. But if you push a new image under the same tag, App Service doesn't notice. Restart the app, or turn on continuous deployment: then a webhook from the registry restarts it for you.

The image is pulled, and the container starts. Now the requests have to reach it. Requests from the internet arrive at the App Service front ends. The front ends end the HTTPS connection, and forward plain HTTP into your container, to a single port. Which port? App Service looks for one on its own: port 80, or port 8080. ASP.NET Core images listen on 8080 since .NET 8, so they work without any setting. Any other port needs one app setting: WEBSITES_PORT. An app with gunicorn on port 8000, or Express on port 3000, sets it to that number. App Service reads this setting itself. Your app never sees it. At startup, App Service sends warm-up requests and waits for any answer, even an error. If nothing answers within the start time limit, the start fails, and App Service tries again. One more trap: your app must listen on 0.0.0.0, on all network interfaces. If it only listens on localhost, the requests from outside never reach it. You can also change how the container starts. A startup command replaces the CMD instruction of your Dockerfile, while the ENTRYPOINT stays. For example, gunicorn, bound to 0.0.0.0 on port 8000.

What happens to a file your app writes? By default, it is gone after the next restart. The container's file system is temporary. When the app restarts, or moves to another machine, everything written inside the container is lost. For files that must survive, App Service can mount persistent storage at /home. For custom Linux containers, it is off by default. Set WEBSITES_ENABLE_APP_SERVICE_STORAGE to true. Now files in /home survive restarts, and every instance of a scaled-out app sees the same files. The space counts against the storage quota of your App Service plan. The /home folder is a network share. That is fine for uploads and output files, but not for a file-based database like SQLite, which needs file locks the share can't give. For large data, mount Azure Storage, or use a real database.

Your app needs different values in development, in staging and in production. Rebuilding the image for each one would defeat the point of a container. App settings solve this. They are name and value pairs that App Service injects as environment variables when the container starts. The image stays the same. Only the variables change. Your code reads them like any environment variable. In Python, that is os.environ. All app settings are encrypted at rest. Their names can use letters, numbers and underscores. So a nested .NET key like ConnectionStrings:Default is written with a double underscore instead of the colon. Connection strings are a special kind of setting. App Service puts a prefix for the database type in front of the name, for example one for Azure SQL. That helps .NET apps. For Python or Node, plain app settings are simpler. With deployment slots, mark the values that belong to an environment as slot settings. When you swap staging into production, the code moves, but slot settings stay with their slot. And for secrets, use a Key Vault reference. The setting's value points to a secret in Azure Key Vault, and App Service fetches it with the app's managed identity. Your code still just reads an environment variable. After a rotation, the new value arrives within 24 hours, or right away when the app restarts.

Settings are in. Now keep the app running well. Without traffic, App Service can unload an idle app after about 20 minutes. The next request then waits for a cold start, and a large image makes that slower. Always On prevents this. App Service then sends a request to your app every five minutes, so it never goes idle. It needs the Basic tier or higher. Health checks go one step further. You give App Service a path, like /health. Every minute, it calls that path on every instance, and expects a status code between 200 and 299. By default, if an instance fails ten checks in a row, App Service takes it out of the load balancer. If it stays unhealthy for an hour, App Service replaces it. This only helps with two or more instances. With a single instance, there is nowhere else to send the traffic. A good health endpoint checks what your app really needs, like its database, and returns an error when it can't reach it.

And when something still goes wrong, you need to see it. App Service captures everything your container writes to standard output and standard error. Turn on container logging, and these lines are saved under /home/LogFiles. The log stream shows them live, from every instance, and each line carries its instance ID. In the CLI, that is az webapp log tail. For a look behind the scenes, open the Kudu site, also called the SCM site. Its environment page lists every variable your app receives. But Kudu runs in its own container, so it can't see the processes inside yours. For that, use SSH. Your image has to bring an SSH server on port 2222, with the root password App Service expects. It is reachable only through Azure, not from the internet. For longer retention, send the logs to a Log Analytics workspace, and query them with KQL. Now most problems have a clear place to look. The container never starts: check the port and the log stream. A setting seems to be missing: check the environment page in Kudu. The new image doesn't show up: check the tag, or restart the app.

To sum it up. App Service pulls your image, best with a managed identity and the AcrPull role. It sends traffic to one port: 80, 8080, or the one you set in WEBSITES_PORT. Files survive only in /home, and only when persistent storage is on. Settings arrive as environment variables, so one image serves every environment. Always On and health checks keep it warm and healthy, and the logs show you what went wrong.

The image stays the same. Everything else plugs in from the outside. If this was useful, subscribe for more practical .NET and Azure.

Need this in your own Azure environment?

I build, migrate and run .NET platforms on Azure, with Azure DevOps and Terraform. If your team wants this set up properly, see what I offer.

See services