Azure Deployment Journey: OCaml Web Application with Dream Framework
A comprehensive guide to deploying OCaml applications on Azure App Service using Azure Container Registry
Project: Chaufr β Personal drivers, on demand, in your
ownΒ vehicle
Tech Stack: OCaml, Dream Framework, PostgreSQL, Azure
App Service, GitHub Actions
What started as a simple deployment task evolved into a comprehensive exploration of Azure's container ecosystem, authentication mechanisms, and optimization strategies. This chronicles our journey deploying an OCaml web application to Azure App Service and the valuable lessons learned along the way.
The Challenge: OCaml on Azure
Azure App Service has excellent built-in support for popular languages like .NET, Python, Node.js, and Java. However, OCaml is not natively supported, presenting several challenges:
- No native OCaml runtime in Azure App Service
- No built-in package manager integration for opam
- Limited documentation for OCaml deployments
- Complex dependency management for OCaml libraries
Why We Chose Azure Container Registry (ACR)
Given the lack of native OCaml support, we decided on a containerized approach using Azure Container Registry:
Benefits:
- Full control over OCaml environment and dependencies
- Reproducible builds across environments
- Integration with Azure App Service container support
- Scalability and enterprise-grade security
- Cost-effective for moderate usage patterns
Our Solution Architecture
High-Level Architecture
GitHub Repository β GitHub Actions β Azure Container Registry β Azure App Service
β β β β
OCaml Source Docker Build Container Image Running App
β β β β
Dream Framework Multi-stage Build Optimized Image PostgreSQL Connection
Components Used
- Azure App Service (B1 Basic) - Cost-effective hosting
- Azure Container Registry - Private container repository
- Azure Database for PostgreSQL - Managed database service
- GitHub Actions - CI/CD pipeline
- Entra ID (Azure AD) - Authentication and authorization
- Application Insights - Monitoring and observability
Major Challenges and Solutions
1. π Entra ID Permission Hurdle
Challenge: GitHub Actions couldn't authenticate with Azure services using OIDC (OpenID Connect).
Error Messages:
ERROR: The user, group or application does not have permissions to read the subscription
Root Cause:
- Service principal lacked proper role assignments
- Incorrect client ID configuration
- Missing federated identity credentials
Solution:
# GitHub Actions workflow configuration
- name: Azure Login (OIDC)
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Steps to Fix:
- Created service principal:
gh-chaufr-deploy
- Assigned Contributor role to resource group
- Configured federated identity credentials for GitHub repository
- Updated GitHub secrets with correct client ID
2. π³ Docker File Drama
Challenge: Multiple Docker-related issues that prevented successful container builds.
Issue 2.1: Missing System Dependencies
Error:
ERROR while compiling conf-libcurl.2
"curl-config": command not found.
The packages you requested declare the following system dependencies:
libcurl4-gnutls-dev libgmp-dev
Solution - Multi-stage Dockerfile:
# Build stage - All development dependencies
FROM ocaml/opam:debian-12-ocaml-5.3 AS builder
WORKDIR /app
RUN sudo apt-get update && sudo apt-get install -y --no-install-recommends \
libpq-dev \
pkg-config \
ca-certificates \
git \
libcurl4-openssl-dev \
libgmp-dev \
libev-dev && sudo rm -rf /var/lib/apt/lists/*
# Runtime stage - Minimal production image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
ca-certificates \
libcurl4 \
libgmp10 \
libev4 && rm -rf /var/lib/apt/lists/*
Issue 2.2: Package Name Differences Between Ubuntu and Debian
Challenge: Local development used Ubuntu 24.04, but Docker used Debian.
Package Mapping Between Ubuntu 24.04 and Debian Bookworm
Component | Ubuntu 24.04 | Debian Bookworm | Notes |
---|---|---|---|
libcurl (dev) | libcurl4-gnutls-dev |
libcurl4-openssl-dev |
|
libcurl (runtime) | libcurl4-gnutls |
libcurl4 |
|
PostgreSQL | libpq5 |
libpq5 |
β Same name |
GMP | libgmp10 |
libgmp10 |
β Same name |
3. π¦ Opam Environment and Dune Build Issues
Challenge: OCaml build tools weren't available in the container environment.
Error:
/bin/sh: 1: dune: not found
Root Cause: The opam environment wasn't activated before running dune build.
Solution:
# Activate opam environment before building
RUN eval $(opam env) && dune build --profile=release ./bin/main.exe
4. π ACR Pull Permission Challenge
Challenge: Azure App Service couldn't pull container images from Azure Container Registry.
Error:
WARNING: No credential was provided to access Azure Container Registry. Trying to look up...
WARNING: Retrieving credentials failed
Solutions Explored:
Option 1: ACR Admin Access (Initial Solution)
- name: Enable ACR admin access and get credentials
run: |
az acr update -n ${{ secrets.ACR_NAME }} --admin-enabled true
ACR_USERNAME=$(az acr credential show -n ${{ secrets.ACR_NAME }} --query username -o tsv)
ACR_PASSWORD=$(az acr credential show -n ${{ secrets.ACR_NAME }} --query passwords[0].value -o tsv)
Option 2: Managed Identity (Recommended Best Practice)
- name: Configure ACR access via Managed Identity
run: |
PRINCIPAL_ID=$(az webapp identity show -g ${{ secrets.RESOURCE_GROUP }} -n $WEBAPP_NAME --query principalId -o tsv)
az role assignment create \
--assignee $PRINCIPAL_ID \
--scope /subscriptions/${{ secrets.AZURE_SUBSCRIPTION_ID }}/resourceGroups/${{ secrets.RESOURCE_GROUP }}/providers/Microsoft.ContainerRegistry/registries/${{ secrets.ACR_NAME }} \
--role "AcrPull"
5. π The Interface Discovery: 0.0.0.0 vs 127.0.0.1
Challenge: OCaml application was accessible locally but returned HTTP 503 errors on Azure.
Root Cause: Network interface binding issue.
Local Development (Working):
Dream.run ~interface:"127.0.0.1" ~port:8080 app
Azure App Service (Failing):
-
Azure's reverse proxy couldn't connect to
127.0.0.1
inside the container - Container was only accepting localhost connections
The Critical Discovery:
let get_interface () =
match Sys.getenv_opt "INTERFACE" with
| Some interface -> interface
| None -> (
match Sys.getenv_opt "WEBSITE_SITE_NAME" with
| Some _ -> "0.0.0.0" (* Azure App Service - allows external connections *)
| None -> "127.0.0.1" (* Local development - secure default *))
Key Insight:
-
0.0.0.0
binds to all network interfaces, allowing Azure's reverse proxy to connect 127.0.0.1
restricts access to localhost only-
Azure App Service automatically sets
WEBSITE_SITE_NAME
environment variable
6. ποΈ Pinned Package Management
Challenge: Using a pinned OCaml package
(simple_dotenv
) that wasn't available in standard opam
repository.
Solution:
# Pin to specific commit hash for caching stability
RUN opam update && \
opam pin add -y simple_dotenv.1.0.0 git+https://github.com/Lomig/simple_dotenv.git#05ef4a35eff29784abc3f454ee36163f3ae48747
Benefits:
- Specific commit hash ensures reproducible builds
- Docker layer caching works effectively
- No dependency on external repository availability during builds
Key Learnings
1. Azure App Service Container Networking
Discovery: Azure App Service uses a reverse proxy
architecture that requires containers to bind to
0.0.0.0
rather than 127.0.0.1
.
Implementation:
(* Dynamic interface selection based on environment *)
let get_interface () =
match Sys.getenv_opt "WEBSITE_SITE_NAME" with
| Some _ -> "0.0.0.0" (* Azure automatically sets this *)
| None -> "127.0.0.1" (* Local development *)
2. GitHub Secrets Management Strategy
What We Used:
AZURE_CLIENT_ID
- Service principal client IDAZURE_TENANT_ID
- Azure tenant identifierAZURE_SUBSCRIPTION_ID
- Target subscriptionACR_NAME
- Azure Container Registry nameRESOURCE_GROUP
- Resource group namePOSTGRES_SERVER
- Database server name-
PG_ADMIN_USER
/PG_ADMIN_PASSWORD
- Database credentials
Security Best Practices:
- Used OIDC authentication instead of storing long-lived secrets
- Implemented credential masking in application logs
- Rotated database passwords after accidental exposure
3. Multi-Stage Docker Build Optimization
Build Stage (Large):
FROM ocaml/opam:debian-12-ocaml-5.3 AS builder
# ~500MB with all development tools
Runtime Stage (Small):
FROM debian:bookworm-slim
# ~150MB with only runtime dependencies
Result: 70% reduction in final image size.
4. OCaml-Specific Deployment Patterns
Dependency Management:
# Copy opam metadata first for better caching
COPY dune-project chaufr.opam ./
RUN opam install -y --deps-only .
# Copy source code last
COPY . .
Environment Activation:
RUN eval $(opam env) && dune build --profile=release ./bin/main.exe
Cost Optimization Strategies
1. GitHub Actions Credits Conservation
Problem: Limited GitHub Actions credits on Pro account.
Docker Build Caching Strategy:
- name: Build and push with cache
uses: docker/build-push-action@v4
with:
cache-from: type=gha
cache-to: type=gha,mode=max
Pinned Dependencies for Cache Stability:
- Pinning to specific commit hashes ensures Docker layer caching works effectively
-
Dependencies layer cached when
dune-project
unchanged - 60-70% build time reduction on subsequent builds
Expected Savings:
- First build: ~8-10 minutes (no cache)
- Subsequent builds: ~2-3 minutes (with cache)
- Monthly credit usage: ~70% reduction
2. Azure Container Registry Pricing
ACR Standard Tier Benefits:
- 100GB storage included
- Unlimited private repositories
- Webhook support for automated deployments
- Geo-replication capabilities
Our Usage Pattern:
- Container images: ~150MB each
- Monthly deployments: ~15-20
- Storage cost: <$5/month
- Data transfer: Minimal (same region)
3. Azure App Service B1 Instance
Why B1 Basic Plan:
- Cost: ~$13/month
- Resources: 1.75GB RAM, 1 vCPU
- Perfect for: MVP and development workloads
- Scaling: Easy upgrade path to higher tiers
Performance Characteristics:
- Container startup: 20-30 seconds
- Health check response: <100ms
- Database connection pooling: 5-10 connections
- Suitable for moderate traffic (~1000 requests/hour)
Final Architecture
Production Deployment Flow
1. Code Push to GitHub
git push origin main
2. GitHub Actions Triggers
- Builds OCaml application in Docker container
- Pushes to Azure Container Registry
- Updates Azure App Service container configuration
- Runs health checks with exponential backoff
3. Azure App Service
- Pulls latest container from ACR
- Starts container with environment variables
- Configures reverse proxy for external access
- Connects to Azure Database for PostgreSQL
Health Check Endpoints
GET /health -> "ok" (basic liveness)
GET /health/detailed -> Database connection status
GET /diagnostic -> Environment variables (masked)
GET /status -> Container runtime information
Environment Variables Configuration
Azure App Service automatically provides:
WEBSITE_SITE_NAME
- App service namePORT
- Container port (8080)
GitHub Actions configures:
DATABASE_URL
- PostgreSQL connection stringENVIRONMENT
- "production"OTEL_SERVICE_NAME
- "chaufr"OTEL_SERVICE_VERSION
- Git commit hash
Security Implementation
Credential Masking:
let mask_database_url url =
try
(* Replace password with *** in connection strings *)
let protocol_end = String.index url '@' in
let colon_pos = String.rindex_from url protocol_end ':' in
let masked_part = String.sub url 0 (colon_pos + 1) ^ "***@" in
let remaining = String.sub url (protocol_end + 1) (String.length url - protocol_end - 1) in
masked_part ^ remaining
with
| Not_found -> "postgresql://***:***@***"
Lessons for Future Developers
1. OCaml on Azure: Containerization is the Way
Don't try to:
- Use Azure App Service built-in language support
- Manually install OCaml runtime in App Service
- Use Azure Functions with custom runtime
Do instead:
- Embrace Docker containerization
- Use multi-stage builds for optimization
- Leverage Azure Container Registry
2. Authentication: OIDC Over Service Principals
Modern approach:
permissions:
id-token: write
contents: read
Benefits:
- No long-lived secrets in GitHub
- Automatic token rotation
- Enhanced security audit trail
3. Environment Detection Pattern
Robust environment detection:
let detect_environment () =
match Sys.getenv_opt "WEBSITE_SITE_NAME" with
| Some site_name -> `Azure_App_Service site_name
| None -> (
match Sys.getenv_opt "GITHUB_ACTIONS" with
| Some _ -> `GitHub_Actions
| None -> `Local_Development)
4. Cost Management Strategies
For GitHub Actions:
- Implement Docker layer caching
- Use specific commit hashes for dependencies
- Add
.dockerignore
to reduce build context
For Azure:
- Start with B1 Basic App Service plan
- Use ACR Standard tier (cost-effective)
- Monitor usage with Application Insights
5. Debugging Techniques
Container startup issues:
- name: Tail logs for debugging
run: |
az webapp log tail -n $WEBAPP_NAME -g ${{ secrets.RESOURCE_GROUP }} --timeout 60 || true
Health check debugging:
- name: Enhanced Health Check
run: |
# Test multiple endpoints with detailed error reporting
for endpoint in "/health" "/diagnostic" "/status" "/"; do
echo "Testing $endpoint..."
HTTP_STATUS=$(curl -s -o response.txt -w "%{http_code}" "$APP_URL$endpoint" || true)
echo "Status: $HTTP_STATUS"
cat response.txt
done
Performance Metrics
Build Times
- Cold build (no cache): 8-10 minutes
- Warm build (with cache): 2-3 minutes
- Cache hit rate: ~80% for typical development
Deployment Times
- Container build: 2-3 minutes (cached)
- ACR push: 30-60 seconds
- App Service deployment: 60-90 seconds
- Health check stabilization: 30-60 seconds
- Total deployment time: 4-6 minutes
Runtime Performance
- Container startup: 20-30 seconds
- Health endpoint response: <100ms
- Database query response: 10-50ms
- Memory usage: ~100MB (OCaml runtime + dependencies)
Future Improvements
1. Enhanced Monitoring
- Implement Application Insights custom metrics
- Add structured logging with JSON format
- Set up Azure Monitor alerts for critical failures
2. Security Hardening
- Implement Azure Key Vault for sensitive configuration
- Add network security groups and private endpoints
- Regular dependency vulnerability scanning
3. Scalability Enhancements
- Implement horizontal scaling triggers
- Add Azure Load Balancer for multi-instance deployments
- Database connection pooling optimization
Conclusion
This journey taught us that deploying OCaml applications to Azure App Service is entirely feasible with the right approach. The key insights:
β
Containerization enables OCaml on Azure
β
Network interface binding (0.0.0.0
) is critical for
Azure App Service
β
OIDC authentication is more secure than traditional service
principals
β
Docker caching strategies significantly reduce CI/CD costs
β
Multi-stage builds optimize both build time and image size
π‘ The labor of love was worth it - we now have a robust, secure, and cost-effective deployment pipeline for OCaml applications on Azure.
For developers embarking on similar journeys: embrace the complexity, document your discoveries, and remember that the modern cloud platforms can support virtually any technology stack with the right architectural decisions.
This documentation represents our collective learning from deploying the Chaufr platform to Azure. We hope it serves as a valuable resource for the OCaml and Azure communities.
Live Application: Will be shared soon, be on the look
out.
Technology Stack: OCaml 5.3, Dream Framework, TyXML,
PostgreSQL, Azure App Service, GitHub Actions.