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β
| Capability | Why It Matters for Agents |
|---|---|
| Serverless containers | Pay only for what you use. Agents that idle between requests scale to zero, cutting costs. |
| Auto-scaling | Traffic spikes (e.g., batch processing, burst user requests) are handled automatically via KEDA scalers. |
| Managed environment | No Kubernetes cluster to manage. Azure handles patching, upgrades, and availability. |
| Revisions | Deploy new agent versions alongside running ones for safe rollouts and rollbacks. |
| Managed identity | Authenticate to Azure services (PostgreSQL, AI Search, Key Vault) without storing secrets. |
| Log Analytics integration | Centralized 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:
| Parameter | Default | Min | Max | Notes |
|---|---|---|---|---|
| Minimum replicas | 0 | 0 | 1,000 | Set to 1+ to prevent cold starts. |
| Maximum replicas | 10 | 1 | 1,000 | Cap to control costs. |
| Scale to zero | Yes | β | β | 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β
| Principle | Why | How |
|---|---|---|
| Multi-stage builds | Smaller final image, faster deployments | Build dependencies in one stage, copy only what's needed. |
Use uv for slim installs | uv is faster than pip and produces leaner environments | uv sync --frozen in the build stage. |
| Non-root user | Security best practice | Create a dedicated appuser and switch to it. |
| Health check | Container Apps uses this for readiness probes | Expose a lightweight /health endpoint. |
| Layer caching | Faster rebuilds during development | Install 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"
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:
| Parameter | Value | Purpose |
|---|---|---|
--min-replicas | 1 | Prevents scale-to-zero cold starts. Set to 0 for cost savings on intermittent agents. |
--max-replicas | 5 | Caps scaling to control costs. Adjust based on expected traffic. |
--cpu / --memory | 1 / 2Gi | Resource 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-identity | managedidentity | Uses 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()
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"
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
internalso 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 .anduv run ruff format .). -
.envfile is in.gitignore(never commit secrets). -
pyproject.tomllists 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:
- Azure Container Apps Best Practices β production patterns for revisions, scaling, security hardening, and cost optimization.
- LangGraph Best Practices β our team's standards for production-grade agent design, including error handling, testing, and performance patterns.
- PostgreSQL Checkpointing & Memory Setup β how to persist agent state to PostgreSQL for durability, time travel, and human-in-the-loop workflows.
- Embeddings + Vector Search on Azure β set up Azure AI Search as your vector database for RAG applications.
- Azure Container Apps Documentation β official Microsoft documentation for advanced features, including jobs, Dapr, and specialized hardware.
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.