Skip to content

How to deploy a uv project to Azure Container Apps

uv

Azure Container Apps runs a container and scales it to zero when idle, the Azure counterpart to the request-driven container platforms in the serverless comparison. Getting a uv project there is one command, az containerapp up, once two things are in place: a container that installs from the frozen lockfile, and a process that listens on the port the platform routes to.

This guide deploys a uv-managed web app (a FastAPI service on uvicorn) to Azure Container Apps.

Write a uv Dockerfile for Container Apps

az containerapp up --source . can build Python source without a Dockerfile, but a Dockerfile gives control over the base image, a multi-stage build, and bytecode compilation. A builder stage installs dependencies with uv, and a slim runtime stage copies only the resulting virtual environment:

Dockerfile
# Build stage: resolve and install dependencies with uv
FROM python:3.13-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:0.11.25 /uv /uvx /bin/

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy

WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev --no-editable

# Runtime stage: copy only the virtual environment
FROM python:3.13-slim

RUN useradd --create-home appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv

ENV PATH="/app/.venv/bin:$PATH"
USER appuser

EXPOSE 8080
CMD ["uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8080"]

uv sync --frozen installs the exact versions in uv.lock and fails if the lockfile is stale, so the deployed environment matches what you tested. UV_COMPILE_BYTECODE=1 writes .pyc files at build time, which shortens startup when Container Apps boots a replica from zero. For the reasoning behind the two-step sync, see How to use uv in a Dockerfile.

Warning

The start command binds --host 0.0.0.0, not 127.0.0.1. Container Apps forwards ingress traffic to the app over the container network, so a process bound to loopback is unreachable and the app never becomes healthy. The port (8080) must match the --target-port value passed at deploy time. The EXPOSE 8080 line lets az containerapp up infer that port, so the two stay in sync.

Deploy with a single az containerapp up

Install the Container Apps CLI extension and register the resource providers the deploy uses. Each runs once per subscription. Microsoft.ContainerRegistry is required because up --source builds the image in Azure Container Registry; a fresh subscription is not registered for it by default:

az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App
az provider register --namespace Microsoft.OperationalInsights
az provider register --namespace Microsoft.ContainerRegistry

Run az containerapp up from the project root, where pyproject.toml, uv.lock, and the Dockerfile live. Because a Dockerfile is present, up builds from it instead of the buildpack:

az containerapp up \
  --name myapp \
  --resource-group myapp-rg \
  --source . \
  --ingress external \
  --target-port 8080

One command creates the resource group, a Container Apps environment named myapp-env with a Log Analytics workspace, an Azure Container Registry, then builds the image, pushes it, and deploys the app. The output prints the public URL. Ship later changes by rerunning with the same resource group and the environment it created:

az containerapp up \
  --name myapp \
  --source . \
  --resource-group myapp-rg \
  --environment myapp-env

Keep a replica warm or let the app scale to zero

A new container app scales to zero by default: minimum replicas 0, maximum 10, with an HTTP scale rule that adds replicas as concurrent requests rise. No charges accrue while the app sits at zero. The cost is a cold start on the first request after idle, while Container Apps starts a replica and the app imports.

To keep one replica always running and avoid that cold start, set a floor:

az containerapp update \
  --name myapp \
  --resource-group myapp-rg \
  --min-replicas 1 \
  --max-replicas 10

Warning

An app with --min-replicas 0 and no ingress has no scale trigger, so it scales to zero and cannot start again. The external ingress configured by az containerapp up supplies the HTTP scale rule that wakes the app, so keep ingress enabled or set --min-replicas 1.

Pass secrets without baking them into the image

Keep API keys and connection strings out of the image and the lockfile. Store each value as a Container Apps secret, then reference it from an environment variable. A secret name is at most 20 characters and lowercase:

az containerapp secret set \
  --name myapp \
  --resource-group myapp-rg \
  --secrets api-key=s3cr3t-value

az containerapp update \
  --name myapp \
  --resource-group myapp-rg \
  --set-env-vars "API_KEY=secretref:api-key"

The secretref: prefix points the environment variable at the stored secret rather than an inline value. The app reads it with os.environ["API_KEY"], and the secret stays out of image layers and the source upload.

Learn more

Last updated on