Containerizing Python Durable Functions: A Step-by-Step Guide for Docker and AKS

I'm a software consultant who loves to build projects and share my learnings on this blog.
Azure Functions is a powerful serverless compute platform, and Azure App Service handles a lot of the underlying configuration for you out of the box. However, there are scenarios where you may want to run your Function App as a container instead - to host it alongside other workloads on Azure Kubernetes Service (AKS), to align with a container-first platform strategy, or to have more control over the runtime environment.
Running a Python Azure Function App in a container is well supported, but a few configuration details that App Service normally provides automatically need to be supplied explicitly when you move to Docker or AKS. This is especially true for Durable Functions, which rely on storage, host identity, and callback URLs being configured correctly.
In this article, we will see how to containerize a Python Durable Functions app end-to-end, covering the Dockerfile, the local development setup with docker-compose, the authentication model, the additional configuration required on AKS, and a CI/CD pipeline pattern that promotes the same image across environments.
Migration checklist
Before getting into the code, here is the short list of things to keep in mind when moving a Durable Functions app from App Service to Docker or AKS:
Use the official
mcr.microsoft.com/azure-functions/python:4-*base image, which ships the Azure Functions host required to run triggers.Set
WEBSITE_HOSTNAMEexplicitly. App Service sets this automatically, but Docker and AKS do not.Point
AzureWebJobsStorageat a real Azure Storage account when running on AKS. Azurite is intended for local development only.Switch
AuthLeveltoANONYMOUSin containers and secure the HTTP endpoints at the ingress layer.Set
AzureFunctionsWebHost__hostidso that all replicas share the same key store.Assign every Function App a unique Durable Task
hubName.Keep secrets and configuration in Kubernetes
SecretsandConfigMaps, rather than baking them into the image.Expose
/api/healthfor liveness and readiness probes.
The rest of the article walks through why each of these matters.
The Dockerfile - multi-stage build with uv
We will use uv for Python dependency management, and a multi-stage build to keep the final image lean by separating the dependency installation from the runtime.
# Dockerfile
# -------------------------------------------------------
# Stage 1 - Build dependencies
# -------------------------------------------------------
ARG UV_IMAGE=ghcr.io/astral-sh/uv
FROM ${UV_IMAGE} AS uvimage
FROM python:3.12-slim AS builder
COPY --from=uvimage /uv /uvx /bin/
WORKDIR /build
COPY pyproject.toml uv.lock ./
RUN uv sync --no-sources --no-dev
# -------------------------------------------------------
# Stage 2 - Production runtime
# -------------------------------------------------------
FROM mcr.microsoft.com/azure-functions/python:4-python3.12
ARG BUILD_NUMBER
ARG SOURCE_VERSION
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
AzureFunctionsJobHost__Logging__Console__IsEnabled=true \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/home/site/wwwroot/.venv/bin:$PATH" \
PYTHONPATH="/home/site/wwwroot/.venv/lib/python3.12/site-packages" \
ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
WORKDIR /home/site/wwwroot
COPY --from=builder /build/.venv /home/site/wwwroot/.venv
COPY . /home/site/wwwroot
RUN echo "BUILD_NUMBER=\({BUILD_NUMBER:-localbuild.\)(date +%Y%m%d%H%M)}" > /home/site/wwwroot/.env && \
echo "SOURCE_VERSION=${SOURCE_VERSION:-local}" >> /home/site/wwwroot/.env
A few lines in this Dockerfile do more work than they appear to, and are worth calling out:
| Decision | Why it matters |
|---|---|
Base image mcr.microsoft.com/azure-functions/python:4-python3.12 |
This image ships the Azure Functions host runtime, which is what loads and dispatches your triggers, including Durable Functions. A plain python:3.12 image will not run Functions triggers. |
AzureWebJobsScriptRoot=/home/site/wwwroot |
The Functions host looks for function_app.py and host.json at this path, so it must match the WORKDIR. |
PATH and PYTHONPATH pointing into .venv |
The Functions host spawns Python workers as child processes that need to discover the installed packages without the virtual environment being activated. |
ASPNETCORE_URLS=http://+:8080 |
Even though the application code is Python, the Functions host itself is an ASP.NET Core process. This setting tells the host which port to bind to inside the container, and must match the port that is EXPOSEd. |
.env written in a RUN step |
.env is excluded by .dockerignore, so COPY . . will not bring a local one in. The RUN step creates it fresh with the build metadata. |
Tip: If your project pulls packages from a private feed such as Azure Artifacts, add the corresponding
ARGdeclarations and credential environment variables to the builder stage and pass them at build time using--build-arg. For anything more sensitive, prefer BuildKit secrets (--mount=type=secret) over build args, since build args remain visible indocker history.
The .dockerignore file
A well-crafted .dockerignore file keeps the build context small, speeds up builds, and prevents local files from accidentally being baked into the image.
# Python & environment
.venv/
env/
venv/
**/__pycache__/
**/*.pyc
# IDEs
.vscode/
.history/
# Git
.git/
.gitignore
# Docker
Dockerfile*
docker-compose.yml
.dockerignore
# Local secrets — DO NOT bake into image
.env
.env.*
local.settings.json
# Logs
*.log
Caution: Always exclude
.envandlocal.settings.json. If a local.envis accidentally copied into the image, it can override the runtime configuration injected at the container level (for example, by Kubernetes), and cause hard-to-debug issues where the running container uses stale or incorrect settings.
Local development with docker-compose and Azurite
Durable Functions requires a storage backend even when running locally. Azurite is the official Azure Storage emulator and provides Blob, Queue, and Table storage, which is exactly what the Durable Task framework relies on.
# docker-compose.yml
networks:
internal-net:
driver: bridge
services:
azurite:
image: mcr.microsoft.com/azure-storage/azurite
container_name: azurite
ports:
- "10000:10000" # Blob
- "10001:10001" # Queue
- "10002:10002" # Table
volumes:
- /data # anonymous volume - cleared on every `down`
networks:
- internal-net
functions-app:
image: my-functions-app:local
build:
context: .
dockerfile: Dockerfile
container_name: functions-app
ports:
- "8080:8080"
env_file:
- .env
networks:
- internal-net
volumes:
- ~/.azure:/root/.azure # forwards your local Azure CLI credentials
depends_on:
- azurite
The minimum .env for local Docker looks like this:
# .env
AzureWebJobsStorage=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;QueueEndpoint=http://azurite:10001/devstoreaccount1;TableEndpoint=http://azurite:10002/devstoreaccount1
WEBSITE_HOSTNAME=localhost:8080
A couple of values in that file are worth calling out specifically.
About
WEBSITE_HOSTNAME: When running in Azure App Service, this variable is set automatically by the platform. In a container, it must be set explicitly. The Durable Functions host uses it to build the callback URLs returned in responses such asstatusQueryGetUriandsendEventPostUri. Without it, the generated URLs use an internal hostname (such as the container ID), which is unreachable from outside the container.
About the Azurite hostname: In the
AzureWebJobsStorageconnection string, the Blob, Queue, and Table endpoints must use the Azurite service name (azurite), notlocalhost. Both containers share the same Docker network and resolve each other by service name. Usinglocalhostwould cause the Functions host to look on its own loopback interface and fail to reach the storage emulator.
Bring everything up with docker compose up --build and call http://localhost:8080/api/health to confirm that the runtime started successfully.
Conditional AuthLevel for Azure and containers
Function key authentication (AuthLevel.FUNCTION) relies on the App Service infrastructure to manage and serve function keys, and is therefore intended for Function Apps hosted on App Service. When the same Function App runs in a container, that key store is not available in the same way, and key-based authentication on HTTP triggers will not work as expected.
A clean approach is to detect the hosting environment at startup and choose the authentication level dynamically. Azure Function App automatically sets the environment variable WEBSITE_SITE_NAME, which is not present in Docker or AKS.
# function_app.py
import os
import logging
import azure.functions as func
import azure.durable_functions as df
logger = logging.getLogger(__name__)
# App Service sets WEBSITE_SITE_NAME; containers do not.
_is_azure_function_app = bool(os.environ.get("WEBSITE_SITE_NAME"))
AUTH_LEVEL = func.AuthLevel.FUNCTION if _is_azure_function_app else func.AuthLevel.ANONYMOUS
logger.info(f"Auth level: {AUTH_LEVEL.name} (App Service detected: {_is_azure_function_app})")
app = df.DFApp(http_auth_level=AUTH_LEVEL)
@app.route(route="data_scoring", auth_level=AUTH_LEVEL, methods=["POST"])
@app.durable_client_input(client_name="client")
async def data_scoring_http_start(req, client):
...
With this in place, the container runs anonymously at the application layer, and the HTTP endpoints are secured one level up by the surrounding infrastructure.
| Environment | Recommended authentication |
|---|---|
| Local Docker | None - intended for development only |
| AKS | NGINX Ingress with OAuth2 Proxy, or Azure API Management in front of the ingress |
| Azure Function App | Function keys, as supported natively by the platform |
The same codebase then runs cleanly on both App Service and in a container, without any special build flags or conditional packaging.
Running on AKS with multiple replicas
A single replica generally runs without any further configuration. When the Function App is scaled to multiple replicas on AKS, however, two additional pieces of configuration become important.
Shared host ID across replicas
Each Functions host instance generates its own host ID on startup, and that host ID determines where its function and system keys are stored within the storage account (azure-webjobs-secrets/<host-id>/). With more than one replica, this results in multiple key stores, and the Durable status URLs returned by one pod are signed with keys that another pod is not aware of. The typical symptom is that statusQueryGetUri intermittently returns 401 Unauthorized.
The fix is to pin the host ID explicitly on every pod, so that all replicas share the same key repository:
# deployment.yaml (excerpt)
env:
- name: AzureFunctionsWebHost__hostid
value: my-functions-app
Now every replica reads and writes the same key repository, and the status URLs work no matter which pod served the original request.
Unique Durable Task Hub per Function App
When more than one Durable Functions app shares the same Azure Storage account, they also share the default Task Hub (DurableFunctionsHub) unless configured otherwise. This in turn causes them to share orchestration state and metadata, and you may see errors such as:
The function '<name>' doesn't exist, is disabled, or is not an orchestrator function.
Known orchestrators: '<a-function-from-the-other-app>'
It is worth noting that AzureFunctionsWebHost__hostid does not provide this isolation. The Task Hub is the isolation boundary for Durable Functions. The fix is to assign a unique hubName per Function App in host.json:
// host.json
{
"extensions": {
"durableTask": {
"hubName": "data-scoring-hub",
"maxConcurrentActivityFunctions": 10,
"maxConcurrentOrchestratorFunctions": 10
}
}
}
Other AKS configuration to apply
Set
WEBSITE_HOSTNAMEin aConfigMapto the ingress hostname of the Function App.Point
AzureWebJobsStorageat a real Azure Storage account, and store the connection string in a KubernetesSecret.Keep all connection strings, API keys, and registry credentials in Kubernetes
Secretsrather than baking them into the image.Wire up
/api/healthas both the liveness and readiness probe target on the deployment.Tune
maxConcurrentActivityFunctionsandmaxConcurrentOrchestratorFunctionsinhost.jsonbased on your workload and node sizes.
CI/CD with docker save and docker load
A common pattern for promoting the same image across environments is to build it once and then push the same artifact to each environment's Azure Container Registry (ACR). In Azure DevOps, when the build and deploy stages run on different self-hosted agent pools (for example, because each environment sits behind its own network boundary), the deploy agent may not be able to pull the freshly built image from the build agent's Docker daemon.
The approach that works well here is to save the built image as a .tar file, publish it as a pipeline artifact, and have the deploy stage load it back into Docker before pushing to ACR:
# azure-pipelines: build stage
- task: Docker@2
displayName: "Build Docker image"
inputs:
command: build
Dockerfile: "$(System.DefaultWorkingDirectory)/Dockerfile"
tags: |
$(Build.BuildNumber)
repository: my-functions-app
arguments: >-
--build-arg BUILD_NUMBER=$(Build.BuildNumber)
--build-arg SOURCE_VERSION=$(Build.SourceVersion)
--build-arg UV_INDEX_PRIVATE_REGISTRY_USERNAME=DevOps
--build-arg UV_INDEX_PRIVATE_REGISTRY_PASSWORD=$(System.AccessToken)
--build-arg UV_KEYRING_PROVIDER=disabled
- bash: |
docker save my-functions-app:$(Build.BuildNumber) \
-o \((Build.ArtifactStagingDirectory)/my-functions-app-\)(Build.BuildNumber).tar
displayName: "Save image as tar"
- publish: "\((Build.ArtifactStagingDirectory)/my-functions-app-\)(Build.BuildNumber).tar"
artifact: docker-my-functions-app
The deploy stage downloads the artifact, loads it, retags for the target ACR, and pushes:
# azure-pipelines: deploy stage
- download: current
artifact: docker-my-functions-app
- bash: |
docker load -i "\((Pipeline.Workspace)/docker-my-functions-app/my-functions-app-\)(Build.BuildNumber).tar"
- task: AzureCLI@2
displayName: "Push image to ACR"
inputs:
azureSubscription: ${{ parameters.azureSubscription }}
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az acr login --name ${{ parameters.acrName }}
docker tag my-functions-app:\((Build.BuildNumber) \){{ parameters.acrName }}.azurecr.io/my-functions-app:$(Build.BuildNumber)
docker push \({{ parameters.acrName }}.azurecr.io/my-functions-app:\)(Build.BuildNumber)
A couple of details in this pipeline are worth highlighting:
Tag the image with
\((Build.BuildNumber)rather than\)(Build.BuildId).BuildNumberis the human-readable run name (for example,20260518.1), which makes it easier to trace a running container back to the pipeline run that built it.Saving the image as a
.taralso provides an immutable snapshot of exactly what was built in that pipeline run, which is useful when investigating issues in a deployed environment.
Troubleshooting cheatsheet
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized on every request |
AuthLevel.FUNCTION used in a container, where the App Service key store is not available |
Switch to AuthLevel.ANONYMOUS and secure the endpoints at the ingress |
| Status URLs contain container or pod ID instead of hostname | WEBSITE_HOSTNAME not set |
Set it to localhost:8080 for local Docker, or to the ingress DNS name for AKS |
Storage account could not be found on startup |
AzureWebJobsStorage uses localhost for the Azurite endpoints |
Use the Docker service name (azurite) in the connection string |
| Stale orchestrations fail after code changes | Old Durable Task state in Azurite | Use an anonymous volume, or run docker compose down -v to reset |
statusQueryGetUri intermittently returns 401 in AKS |
Replicas generated different host IDs | Set AzureFunctionsWebHost__hostid on every pod |
| "Orchestrator not found" or wrong orchestrators listed | Multiple Function Apps sharing one Task Hub | Set a unique hubName in each app's host.json |
Local .env overrides runtime configuration in the container |
.env not excluded from the build context |
Add .env and .env.* to .dockerignore |
| Python packages not found at runtime | PATH or PYTHONPATH not pointing into the virtual environment |
Set both in the Dockerfile ENV directive |
Containerizing a Python Azure Functions app is largely about being explicit about the configuration that App Service provides automatically - the host ID, the Task Hub, the public hostname, and the authentication model. Once these are set correctly, the same Function App runs reliably in Docker and on AKS, integrates with the rest of your container platform, and ships through the same CI/CD pipeline as everything else, while still benefiting from the simplicity of the Azure Functions programming model.
The same image can also be deployed to an Azure Function App on App Service whenever that is the better hosting choice, with no code changes required.
Happy coding!




