Skip to main content

Deploying Agents to Container Apps

You've built a LangGraph agent locally. It loops, calls tools, and produces answers. Now it needs to run in production β€” reliably, scalably, and securely. This guide walks you through deploying your agent to Azure Container Apps, our team's standard platform for serving AI agents as serverless containerized workloads.

By the end of this article, you will have:

  • A production-ready Dockerfile for a Python/LangGraph agent using multi-stage builds with uv.
  • A Container App deployed via Azure CLI, configured with managed identity and environment variables.
  • Your agent wired to PostgreSQL (checkpointing), Azure AI Search (RAG), and Langfuse (observability).
  • A local development workflow using Docker Compose that mirrors the production environment.

What Is Azure Container Apps​

Azure Container Apps is a serverless platform for running containerized applications without managing underlying infrastructure. Instead of configuring VMs, orchestrating Kubernetes clusters, or handling deployment details yourself, Container Apps provides the resources that keep your applications stable, secure, and scalable.

Why We Use It for Agents​

CapabilityWhy It Matters for Agents
Serverless containersPay only for what you use. Agents that idle between requests scale to zero, cutting costs.
Auto-scalingTraffic spikes (e.g., batch processing, burst user requests) are handled automatically via KEDA scalers.
Managed environmentNo Kubernetes cluster to manage. Azure handles patching, upgrades, and availability.
RevisionsDeploy new agent versions alongside running ones for safe rollouts and rollbacks.
Managed identityAuthenticate to Azure services (PostgreSQL, AI Search, Key Vault) without storing secrets.
Log Analytics integrationCentralized logging and monitoring for debugging agent runs in production.

How Scaling Works​

Container Apps uses KEDA (Kubernetes Event-driven Autoscaling) to scale based on:

  • HTTP traffic β€” number of concurrent requests (default scaler).
  • CPU or memory load β€” resource utilization thresholds.
  • Event-driven triggers β€” queue depth, cron schedules, custom scalers.

Key scaling parameters:

ParameterDefaultMinMaxNotes
Minimum replicas001,000Set to 1+ to prevent cold starts.
Maximum replicas1011,000Cap to control costs.
Scale to zeroYesβ€”β€”Apps scaling on CPU/memory cannot scale to zero.

When a new revision scales out, Container Apps creates new replicas on demand. You are not billed for idle replicas that scale to zero, and non-processing replicas may be billed at a lower "idle" rate.


Prerequisites​

Before deploying, ensure you have the following in place.

Azure Subscription and Permissions​

You need an active Azure subscription with at least Contributor role on the target resource group. If you do not have one, ask your team lead to grant access.

Azure CLI Installed and Logged In​

# Install Azure CLI (if not already installed)
# macOS: brew install azure-cli
# Linux: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

# Install the Container Apps extension
az extension add --name containerapp --upgrade

# Log in
az login

# Verify your subscription
az account show --query "{name: name, id: id}"

Resource Group​

Container Apps requires a resource group. Create one if your team does not have a shared one:

RESOURCE_GROUP="rg-ai-engineering"
LOCATION="swedencentral"

az group create \
--name "$RESOURCE_GROUP" \
--location "$LOCATION"

Container Apps Environment​

The Container Apps Environment is the foundational resource. Create it once and reuse it across all your agent deployments. It provides the networking, logging, and Dapr infrastructure that Container Apps runs on.

ENVIRONMENT_NAME="cae-ai-agents"

az containerapp env create \
--name "$ENVIRONMENT_NAME" \
--resource-group "$RESOURCE_GROUP" \
--location "$LOCATION"

Container Registry​

You need Azure Container Registry (ACR) to store your Docker images. Container Apps pulls images from ACR at deployment time.

# ACR names must be globally unique (5-50 lowercase alphanumeric)
REGISTRY_NAME="aicaeng$(shuf -i 1000-9999 -n 1)"

az acr create \
--name "$REGISTRY_NAME" \
--resource-group "$RESOURCE_GROUP" \
--location "$LOCATION" \
--sku Basic \
--admin-enabled true

Containerizing Your Agent​

A well-built Docker image is the foundation of a production deployment. This section covers writing a Dockerfile optimized for Python/LangGraph agents.

Production Dockerfile Principles​

PrincipleWhyHow
Multi-stage buildsSmaller final image, faster deploymentsBuild dependencies in one stage, copy only what's needed.
Use uv for slim installsuv is faster than pip and produces leaner environmentsuv sync --frozen in the build stage.
Non-root userSecurity best practiceCreate a dedicated appuser and switch to it.
Health checkContainer Apps uses this for readiness probesExpose a lightweight /health endpoint.
Layer cachingFaster rebuilds during developmentInstall dependencies before copying source code.

Example: FastAPI + LangGraph Agent Dockerfile​

This Dockerfile targets a LangGraph agent served via FastAPI. It uses uv for dependency management and a multi-stage build for a minimal final image.

# --- Stage 1: Build dependencies with uv ---
FROM ghcr.io/astral-sh/uv:python3.11-slim AS build

WORKDIR /app

# Copy dependency manifests first (for layer caching)
COPY pyproject.toml uv.lock ./

# Install production dependencies only
RUN uv sync --frozen --no-install-project --no-dev

# --- Stage 2: Runtime image ---
FROM python:3.11-slim AS runtime

WORKDIR /app

# Create a non-root user for security
RUN useradd --create-home appuser

# Copy the virtual environment from the build stage
COPY --from=build /app/.venv /app/.venv

# Copy application source code
COPY src/ ./src/

# Set environment variables
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1

# Switch to non-root user
USER appuser

# Health check β€” Container Apps calls this to verify readiness
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

# Expose the port Container Apps will route traffic to
EXPOSE 8000

# Start the application
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

The pyproject.toml for the Agent​

[project]
name = "research-agent"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"langgraph>=0.6",
"langchain>=0.3",
"langchain-openai>=0.3",
"langchain-azure-ai-search>=0.2",
"langgraph-checkpoint-postgres>=3.1",
"psycopg[binary]>=3.2",
"psycopg-pool>=3.2",
"azure-identity>=1.20",
"azure-search-documents>=11.6",
"python-dotenv>=1.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.9",
"httpx>=0.28",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

The FastAPI Application Entry Point​

# src/main.py
import os
import uuid

import redis
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from src.agent import create_agent
from src.config import settings

app = FastAPI(
title="Research Agent API",
version=os.environ.get("AGENT_VERSION", "1.0.0"),
)

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)

# Create the agent at startup (singleton pattern)
agent = create_agent()


@app.get("/health")
async def health_check():
"""Health endpoint for Container Apps readiness probes."""
return {"status": "healthy", "version": os.environ.get("AGENT_VERSION", "unknown")}


class ChatRequest(BaseModel):
"""Request body for agent invocations."""
message: str
thread_id: str | None = None


class ChatResponse(BaseModel):
"""Response from the agent."""
response: str
thread_id: str
step_count: int


@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""Invoke the LangGraph agent with the user's message."""
thread_id = request.thread_id or str(uuid.uuid4())

try:
result = agent.invoke(
{"messages": [{"role": "user", "content": request.message}]},
config={"configurable": {"thread_id": thread_id}},
)
return ChatResponse(
response=result["messages"][-1]["content"],
thread_id=thread_id,
step_count=result.get("step_count", 0),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))

Deploying to Container Apps​

This section walks through the complete deployment pipeline: building the image, pushing it to ACR, creating the Container App, and verifying it works.

Step 1 β€” Build and Push the Docker Image​

# Set variables
RESOURCE_GROUP="rg-ai-engineering"
REGISTRY_NAME="aicaeng1234" # Replace with your ACR name
IMAGE_NAME="research-agent"
IMAGE_TAG="1.0.0"

# Build the Docker image
docker build -t "$REGISTRY_NAME.azurecr.io/$IMAGE_NAME:$IMAGE_TAG" .

# Log in to ACR
az acr login --name "$REGISTRY_NAME"

# Push the image
docker push "$REGISTRY_NAME.azurecr.io/$IMAGE_NAME:$IMAGE_TAG"
Accelerated Builds with ACR Tasks

For larger projects, consider using Azure Container Registry build tasks to build images directly in Azure:

az acr build \
--registry "$REGISTRY_NAME" \
--image "$IMAGE_NAME:$IMAGE_TAG" \
--file Dockerfile \
.

This avoids transferring large images over your local network.

Step 2 β€” Create the Container App​

APP_NAME="research-agent"
ENVIRONMENT_NAME="cae-ai-agents"

az containerapp create \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--environment "$ENVIRONMENT_NAME" \
--image "$REGISTRY_NAME.azurecr.io/$IMAGE_NAME:$IMAGE_TAG" \
--registry-server "$REGISTRY_NAME.azurecr.io" \
--target-port 8000 \
--ingress external \
--transport auto \
--min-replicas 1 \
--max-replicas 5 \
--cpu 1 \
--memory 2Gi \
--registry-identity "managedidentity"

Key parameters explained:

ParameterValuePurpose
--min-replicas1Prevents scale-to-zero cold starts. Set to 0 for cost savings on intermittent agents.
--max-replicas5Caps scaling to control costs. Adjust based on expected traffic.
--cpu / --memory1 / 2GiResource limits per replica. LangGraph agents with LLM calls need headroom.
--ingress externalβ€”Makes the app publicly accessible via a DNS name. Use internal for VNet-only access.
--registry-identitymanagedidentityUses the app's managed identity to authenticate to ACR.

Step 3 β€” Configure Environment Variables​

Agent-specific configuration (non-secret) goes as environment variables:

az containerapp appsettings set \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--settings \
ENVIRONMENT=production \
AGENT_VERSION=1.0.0 \
LANGGRAPH_STRICT_MSGPACK=true \
AZURE_OPENAI_API_VERSION=2024-10-01-preview \
AZURE_SEARCH_INDEX_NAME=rag-documents

Step 4 β€” Set Up Managed Identity for Secret-Free Authentication​

Managed identity is our team's security standard for authenticating to Azure services. It eliminates the need to store API keys, connection strings, or credentials in environment variables.

Assign a Managed Identity to the Container App​

# Container Apps automatically creates a system-assigned managed identity.
# Enable it if not already enabled:
az containerapp identity assign \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--system-assigned

# Get the managed identity principal ID for role assignments
IDENTITY_PRINCIPAL_ID=$(az containerapp identity show \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--query principalId \
--output tsv)

echo "Managed Identity Principal ID: $IDENTITY_PRINCIPAL_ID"

Grant Access to Azure Services​

# Get the resource group ID and subscription ID
SUBSCRIPTION_ID=$(az account show --query id --output tsv)
RG_ID="/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP"

# Grant access to Azure Container Registry (for pulling images)
az role assignment create \
--assignee "$IDENTITY_PRINCIPAL_ID" \
--role "AcrPull" \
--scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.ContainerRegistry/registries/$REGISTRY_NAME"

# Grant access to Azure AI Search (for RAG queries)
az role assignment create \
--assignee "$IDENTITY_PRINCIPAL_ID" \
--role "Search Index Data Contributor" \
--scope "$RG_ID/providers/Microsoft.Search/searchServices/ai-eng-search-prod"

# Grant access to Azure OpenAI (for LLM calls)
az role assignment create \
--assignee "$IDENTITY_PRINCIPAL_ID" \
--role "Cognitive Services OpenAI User" \
--scope "$RG_ID/providers/Microsoft.CognitiveServices/accounts/ai-eng-openai"

Use Managed Identity in Application Code​

# src/config.py
from azure.identity import DefaultAzureCredential
from azure.search.documents import SearchClient
from langchain_openai import AzureChatOpenAI, AzureOpenAIEmbeddings


class Settings:
"""Application settings loaded from environment and Azure services."""

# Managed identity credential β€” no keys needed
credential = DefaultAzureCredential()

# Azure OpenAI via managed identity
llm = AzureChatOpenAI(
model="gpt-4o",
azure_deployment="gpt-4o",
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-01-preview"),
azure_ad_token_provider=lambda: credential.get_token(
"https://cognitiveservices.azure.com/.default"
).token,
)

embeddings = AzureOpenAIEmbeddings(
model="text-embedding-3-small",
azure_deployment="embeddings-small",
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-01-preview"),
azure_ad_token_provider=lambda: credential.get_token(
"https://cognitiveservices.azure.com/.default"
).token,
)

# Azure AI Search via managed identity
search_client = SearchClient(
endpoint=os.environ["AZURE_SEARCH_ENDPOINT"],
index_name=os.environ.get("AZURE_SEARCH_INDEX_NAME", "rag-documents"),
credential=credential,
)


settings = Settings()
Managed Identity Authentication Flow

When using managed identity, the application acquires tokens at runtime via DefaultAzureCredential(). This means:

  • No API keys are stored in environment variables or code.
  • Token caching is handled automatically by the Azure SDK.
  • Local development still works β€” DefaultAzureCredential() falls back to Azure CLI credentials when run locally.

Step 5 β€” Store Sensitive Configuration as Secrets​

For values that must be secrets (like database passwords during migration, or third-party API keys that lack managed identity support), use Container Apps secrets:

az containerapp secret set \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--secrets \
postgres-password="<your-db-password>"

Then reference the secret as an environment variable:

az containerapp appsettings set \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--settings \
"LANGGRAPH_DB_URI=postgresql://langgraph_user:@postgres-server.postgres.database.azure.com:5432/langgraph?sslmode=require"
Prefer Managed Identity Over Secrets

Whenever possible, use managed identity instead of storing secrets. Secrets in Container Apps are encrypted at rest but are still visible in the Azure Portal and CLI output. Managed identity tokens are never exposed to the application code.

Step 6 β€” Verify Deployment and Test the Endpoint​

# Check the app status
az containerapp show \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--query "{name: name, status: properties.configuration.activeRevisions[0].name, replicas: properties.latestRevision}"

# Get the app URL
APP_URL=$(az containerapp show \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--query configuration.ingress.fqdn \
--output tsv)

echo "App URL: https://$APP_URL"

# Test the health endpoint
curl -s "https://$APP_URL/health"

# Test the agent endpoint
curl -s -X POST "https://$APP_URL/chat" \
-H "Content-Type: application/json" \
-d '{"message": "What is LangGraph?"}'

Connecting Services​

A LangGraph agent in production needs more than just an LLM. It needs persistent state, a knowledge base, and observability. This section covers wiring up the full stack.

Connecting to PostgreSQL for LangGraph Checkpointing​

PostgreSQL is our team's standard for LangGraph checkpointing. Container Apps connects to PostgreSQL over the network β€” no special integration needed.

# src/checkpointer.py
import os
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver


def create_checkpointer() -> PostgresSaver:
"""Create a PostgreSQL checkpointer with connection pooling."""
db_uri = os.environ["LANGGRAPH_DB_URI"]

pool = ConnectionPool(
conninfo=db_uri,
min_size=3,
max_size=10,
timeout=5,
open=True,
)

checkpointer = PostgresSaver(pool)
checkpointer.setup()
return checkpointer

For Azure Database for PostgreSQL, ensure the Container Apps environment is whitelisted in the firewall rules or use a private endpoint:

# Add the Container Apps environment's outbound IP to the PostgreSQL firewall
# Or better β€” use a private endpoint for VNet integration:

az network private-endpoint create \
--name pe-postgres-langgraph \
--resource-group "$RESOURCE_GROUP" \
--vnet-name "vnet-ai-agents" \
--subnet "subnet-private-endpoints" \
--private-connection-resource-id "$POSTGRES_RESOURCE_ID" \
--group-ids postgresqlServer \
--connection-name "pe-postgres-connection"

Connecting to Azure AI Search for RAG​

The search client connects via managed identity (created in Step 4 above). Here is the full RAG retrieval node for your LangGraph agent:

# src/tools.py
import os

from langchain.tools import tool
from src.config import settings


@tool
def search_knowledge_base(query: str) -> str:
"""Search the knowledge base for relevant documents.

Args:
query: The search query string.
"""
# Embed the query
query_vector = settings.embeddings.embed_query(query)

# Hybrid search: keyword + vector
results = settings.search_client.search(
search_text=query,
vector_queries=[{
"kind": "vector",
"fields": "embedding",
"k_nearest_neighbors": 50,
}],
top=5,
select=["content", "source"],
)

# Format results for the LLM
formatted = []
for doc in results:
formatted.append(
f"[Source: {doc.get('source', 'unknown')}]\n{doc.get('content', '')}"
)

return "\n\n---\n\n".join(formatted) if formatted else "No relevant documents found."

Connecting to Langfuse for Observability​

Langfuse provides observability for agent runs β€” tracing, debugging, and evaluation. Configure it as an environment variable:

az containerapp appsettings set \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--settings \
LANGFUSE_PUBLIC_KEY=pk-lf-... \
LANGFUSE_SECRET_KEY=sk-lf-... \
LANGFUSE_HOST=https://cloud.langfuse.com

Then enable it in your agent code:

# src/observability.py
import langfuse
from langfuse.decorators import observe


# Initialize Langfuse
langfuse_client = langfuse.Langfuse()


@observe(name="research-agent")
def run_agent(user_message: str, thread_id: str) -> dict:
"""Run the agent with Langfuse tracing."""
from src.agent import agent

result = agent.invoke(
{"messages": [{"role": "user", "content": user_message}]},
config={"configurable": {"thread_id": thread_id}},
)

# Log metadata to Langfuse
langfuse_client.update_current_trace(
input={"message": user_message},
output={"step_count": result.get("step_count", 0)},
metadata={"thread_id": thread_id},
)

return result

Using VNet Integration for Private Connectivity​

For production deployments, Container Apps can integrate with a Virtual Network (VNet) to communicate with services privately:

# Create a VNet and subnet for the Container Apps environment
az network vnet create \
--name "vnet-ai-agents" \
--resource-group "$RESOURCE_GROUP" \
--location "$LOCATION" \
--address-prefix "10.0.0.0/16" \
--subnet-name "cae-subnet" \
--subnet-prefix "10.0.1.0/24"

# Recreate the Container Apps environment with VNet integration
az containerapp env create \
--name "$ENVIRONMENT_NAME" \
--resource-group "$RESOURCE_GROUP" \
--location "$LOCATION" \
--infrastructure-subnet-id "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Network/virtualNetworks/vnet-ai-agents/subnets/cae-subnet"

With VNet integration:

  • All traffic between the Container App and Azure services stays within the Azure backbone network.
  • You can use private endpoints instead of public endpoints for PostgreSQL, AI Search, and other services.
  • Ingress can be set to internal so the app is only reachable from within the VNet.

Local Development Workflow​

Deploying to Azure is one thing. Developing efficiently is another. This section covers setting up a local environment that mirrors production.

Docker Compose for Local Service Stack​

Use Docker Compose to run PostgreSQL, a mock search service, and Langfuse locally:

# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: langgraph_user
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: langgraph
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U langgraph_user"]
interval: 10s
timeout: 5s
retries: 5

langfuse:
image: langfuse/langfuse:latest
ports:
- "3000:3000"
environment:
DATABASE_URL: "postgresql://postgres:password@langfuse-db:5432/langfuse"
SALT: "dev-salt-change-in-production"
NEXTAUTH_SECRET: "dev-secret-change-in-production"
NEXTAUTH_URL: "http://localhost:3000"

langfuse-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: langfuse
volumes:
- langfuse_db_data:/var/lib/postgresql/data

volumes:
postgres_data:
langfuse_db_data:

Start the stack:

docker compose up -d

Environment Configuration for Local Development​

Create a .env file for local overrides:

# .env
ENVIRONMENT=development
AGENT_VERSION=1.0.0-dev
LANGGRAPH_DB_URI=postgresql://langgraph_user:dev_password@localhost:5432/langgraph
LANGGRAPH_STRICT_MSGPACK=true

# Azure OpenAI (use your local credentials or Azure CLI auth)
AZURE_OPENAI_ENDPOINT=https://ai-eng-openai.openai.azure.com
AZURE_OPENAI_API_VERSION=2024-10-01-preview

# Azure AI Search (local dev β€” point to dev instance)
AZURE_SEARCH_ENDPOINT=https://ai-eng-search-dev.search.windows.net
AZURE_SEARCH_INDEX_NAME=rag-documents

# Langfuse (local)
LANGFUSE_PUBLIC_KEY=pk-lf-dev
LANGFUSE_SECRET_KEY=sk-lf-dev
LANGFUSE_HOST=http://localhost:3000

Hot Reload During Development​

For fast iteration, run the agent with hot reload instead of rebuilding Docker images:

# Install dev dependencies
uv sync --all-extras

# Run with hot reload
uv run uvicorn src.main:app \
--host 0.0.0.0 \
--port 8000 \
--reload \
--env-file .env

The --reload flag enables auto-reload on file changes. Combined with uv's fast environment setup, you get sub-second iteration cycles during development.

Testing Against Local Services​

# tests/test_agent.py
import pytest
import httpx
from fastapi.testclient import TestClient

from src.main import app


@pytest.fixture
def client():
"""Create a test client for the FastAPI app."""
return TestClient(app)


def test_health_endpoint(client: TestClient):
"""Health endpoint returns 200 with healthy status."""
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"


def test_chat_endpoint(client: TestClient):
"""Chat endpoint returns a response with a thread ID."""
response = client.post(
"/chat",
json={"message": "What is LangGraph?"},
)
assert response.status_code == 200
data = response.json()
assert "response" in data
assert "thread_id" in data
assert data["step_count"] >= 0

Run the test suite:

uv run pytest -v

From Local to Production Checklist​

Before pushing to Container Apps, verify:

  • Dockerfile builds successfully (docker build -t test .).
  • Health endpoint responds (curl localhost:8000/health).
  • Agent responds to a test query (curl -X POST localhost:8000/chat -d '{"message":"test"}').
  • Tests pass (uv run pytest -v).
  • Lint and type-check pass (uv run ruff check . and uv run ruff format .).
  • .env file is in .gitignore (never commit secrets).
  • pyproject.toml lists all production dependencies.

Monitoring and Logs​

Container Apps integrates with Azure Log Analytics for centralized logging. Application logs from stdout and stderr are automatically captured.

Viewing Logs in the Azure CLI​

# Stream live logs
az containerapp logs show \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--follow

# View recent logs
az containerapp logs show \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--lines 100

Structured Logging for Better Observability​

Configure your application to emit structured logs:

# src/logging.py
import json
import logging
import sys


class JsonFormatter(logging.Formatter):
"""Format log records as JSON for structured log ingestion."""

def format(self, record: logging.LogRecord) -> str:
log_entry = {
"level": record.levelname,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry)


def setup_logging() -> None:
"""Configure structured JSON logging."""
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())

root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(handler)

Log Analytics Queries​

Once logs are flowing to Log Analytics, you can query them:

// ContainerAppConsoleLogs
// Show recent error logs
ContainerAppConsoleLogs_CL
| where Log_s contains "ERROR"
| project TimeGenerated, ContainerName_s, Log_s
| order by TimeGenerated desc
| limit 50

// Show agent invocation counts by hour
ContainerAppConsoleLogs_CL
| where Log_s contains "step_count"
| parse Log_s with * "\"step_count\":" step_count:int *
| summarize count() by bin(TimeGenerated, 1h)
| render timechart

Next Steps​

You now have a LangGraph agent containerized, deployed to Azure Container Apps, and connected to the services it needs. Here is where to go next:

Revision Strategy

Every change to your Container App (new image, new environment variable, scaling rule change) creates a new revision. Revisions are immutable snapshots of your app. Use them for safe deployments: keep the old revision running while the new one scales up, then cut traffic when you are confident. Read our Best Practices guide for a detailed revision management strategy.