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 on0.0.0.0, notlocalhost. - Pull with a managed identity. Give the web app's identity the
AcrPullrole on the registry; no password is stored anywhere. - The container's file system is temporary. Only
/homesurvives restarts, and only when persistent storage is on (the app settingWEBSITES_ENABLE_APP_SERVICE_STORAGEset totrue). - 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 identity | Admin user | |
|---|---|---|
| What it is | an identity in Microsoft Entra ID that belongs to the web app | a username and password stored in the registry |
| Access | the AcrPull role on the registry | the admin user has to be enabled on the registry |
| Secrets | none stored anywhere | the password sits in the app's configuration |
| Rotation | nothing to rotate | manual |
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:v1az webapp identity assign -g rg -n docprocaz role assignment create --assignee $PRINCIPAL_ID \--role AcrPull --scope $ACR_IDaz 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=8000az 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 oslog_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:Defaultis written asConnectionStrings__Default. - Connection strings get a prefix for the database type in front of the name, for example
SQLAZURECONNSTR_Defaultfor 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 happens | When |
|---|---|
| The instance leaves the load balancer | after ten failed checks in a row (by default) |
| It returns to the load balancer | as soon as it answers healthy again |
| It is replaced | after 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"}, 503return {"status": "healthy"}, 200
az webapp config set -g rg -n docproc --always-on trueaz 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 filesystemaz 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.
| Symptom | Where to look |
|---|---|
| The container never starts | the port (WEBSITES_PORT, 0.0.0.0) and the log stream |
| A setting seems to be missing | the environment page in Kudu |
| The new image doesn't show up | the tag, or restart the app |


