#
Deploying htmx Applications on Azure
Building an htmx application is only half the work. Getting it running reliably in production requires careful configuration, proper infrastructure, and automated deployment pipelines. This chapter takes the Chinook Dashboard from development to a production-ready Azure deployment.
#
23.1 Introduction
#
Why Deployment Matters for htmx Applications
htmx applications have specific characteristics that affect deployment strategy. Understanding these helps you make better infrastructure decisions.
Latency Sensitivity: htmx makes frequent small requests. Each button click, form submission, or search keystroke triggers a server round-trip. High latency makes applications feel sluggish. Server proximity to users matters more than with traditional page-based applications.
Partial Response Compression: htmx responses are often small HTML fragments. A table row might be 500 bytes, a form 2KB. Compression algorithms work best on larger payloads, but even small gains add up when users generate dozens of requests per session.
Caching Opportunities: Some htmx responses are highly cacheable. A genre dropdown or static list doesn't change often. Proper cache headers reduce server load and improve response times.
Static Asset Optimization: htmx.js, Hyperscript, and your CSS are requested on every page load. CDN delivery and aggressive caching for these files significantly improves initial page load.
#
Azure Hosting Options
Azure offers several ways to host ASP.NET Core applications:
Azure App Service is the focus of this chapter. It provides managed hosting with automatic OS patching, built-in load balancing, deployment slots, and easy scaling. Best for most web applications.
Azure Container Apps runs containerized applications with automatic scaling, including scale-to-zero. Good if you want container portability without managing Kubernetes.
Azure Kubernetes Service (AKS) provides full Kubernetes orchestration. Best for complex microservices architectures or teams already invested in Kubernetes.
Azure Static Web Apps hosts static files with optional API backends. Not ideal for htmx applications since server-side rendering is central to the pattern.
For the Chinook Dashboard, App Service provides the right balance of simplicity, features, and cost. The patterns in this chapter apply to other hosting options with minor modifications.
#
What This Chapter Covers
This chapter walks through:
- Preparing the application for production (configuration, compression, assets)
- Creating Azure resources (App Service, SQL Database, Key Vault)
- Building CI/CD pipelines with GitHub Actions
- htmx-specific deployment considerations (caching partials, error handling)
- Monitoring with Application Insights
- Scaling and performance optimization
- Security hardening
By the end, you'll have a fully automated pipeline that builds, tests, and deploys your htmx application to Azure.
#
23.2 Preparing Your Application for Production
Before deploying, configure your application for production workloads. Development defaults prioritize convenience; production requires security, performance, and reliability.
#
23.2.1 Environment-Specific Configuration
ASP.NET Core loads configuration from multiple sources in a specific order. Later sources override earlier ones:
appsettings.json(base configuration)appsettings.{Environment}.json(environment-specific)- Environment variables
- Command-line arguments
The ASPNETCORE_ENVIRONMENT variable determines which environment file loads. Azure App Service sets this to "Production" by default.
appsettings.Production.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"ChinookDashboard": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"ChinookConnection": "SET_IN_AZURE_APP_SETTINGS"
},
"ApplicationInsights": {
"ConnectionString": "SET_IN_AZURE_APP_SETTINGS"
},
"Htmx": {
"Timeout": 30000,
"HistoryCacheSize": 10,
"DefaultSwapStyle": "innerHTML",
"DefaultSettleDelay": 20,
"IncludeIndicatorStyles": true
},
"Caching": {
"StaticFilesCacheMaxAge": 23536000,
"PartialResponsesCacheMaxAge": 0,
"EnableResponseCaching": true
},
"Security": {
"EnableHsts": true,
"HstsMaxAgeDays": 365,
"EnableHttpsRedirection": true
},
"FeatureFlags": {
"EnableDetailedErrors": false,
"EnableSwagger": false,
"EnableDeveloperExceptionPage": false
}
}
Key points about this configuration:
- Logging levels: Set Microsoft namespaces to Warning to reduce noise. Keep your application namespace at Information for useful operational logs.
- Connection strings: Use placeholder values. Actual secrets come from Azure App Settings or Key Vault.
- htmx settings: These values can be injected into your layout for client-side configuration.
- Feature flags: Disable development features in production.
Managing Secrets
Never commit secrets to source control. The appsettings.Production.json contains structure but not actual secrets. Sensitive values come from:
- Azure App Settings: Set in the Azure Portal or via CLI
- Azure Key Vault: For highly sensitive secrets with audit logging
- Environment variables: Set by the deployment platform
# Don't do this - secrets in code
"ConnectionStrings": {
"ChinookConnection": "Server=myserver.database.windows.net;Password=secret123"
}
# Do this - placeholder that Azure overrides
"ConnectionStrings": {
"ChinookConnection": "SET_IN_AZURE_APP_SETTINGS"
}
#
23.2.2 Optimizing Static Assets
Production applications should serve minified, cached static files.
#
Client-Side Library Management with LibMan
LibMan (Library Manager) manages client-side dependencies without npm complexity.
libman.json
{
"version": "1.0",
"defaultProvider": "cdnjs",
"libraries": [
{
"library": "htmx.org@1.9.10",
"destination": "wwwroot/lib/htmx/",
"files": [
"htmx.min.js",
"htmx.js"
]
},
{
"library": "hyperscript.org@0.9.12",
"destination": "wwwroot/lib/hyperscript/",
"files": [
"_hyperscript.min.js",
"_hyperscript.js"
]
}
]
}
Restore libraries during build:
dotnet tool install -g Microsoft.Web.LibraryManager.Cli
libman restore
#
Bundling and Minification
For custom CSS and JavaScript, use WebOptimizer or BundlerMinifier:
bundleconfig.json
[
{
"outputFileName": "wwwroot/css/site.min.css",
"inputFiles": [
"wwwroot/css/site.css",
"wwwroot/css/htmx-indicators.css",
"wwwroot/css/toast.css"
],
"minify": {
"enabled": true,
"renameLocals": true
}
},
{
"outputFileName": "wwwroot/js/site.min.js",
"inputFiles": [
"wwwroot/js/htmx-config.js",
"wwwroot/js/toast-handler.js"
],
"minify": {
"enabled": true
}
}
]
#
Static File Caching
Configure aggressive caching for static files. Fingerprinted assets (with hash in filename) can cache for one year.
// In Program.cs
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
// Cache static files for 1 year
const int durationInSeconds = 60 * 60 * 24 * 365;
ctx.Context.Response.Headers.Append(
"Cache-Control", $"public, max-age={durationInSeconds}");
}
});
For versioned files (using asp-append-version tag helper), the cache is automatically invalidated when files change:
<link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
<script src="~/lib/htmx/htmx.min.js" asp-append-version="true"></script>
#
23.2.3 Response Compression
Compression reduces bandwidth and improves response times. htmx partial responses benefit even though they're small.
#
Why Compression Matters for htmx
Consider a typical htmx interaction:
- Search results partial: 3KB uncompressed → 800 bytes compressed (73% reduction)
- Edit form partial: 2KB uncompressed → 500 bytes compressed (75% reduction)
- Table row: 500 bytes uncompressed → 180 bytes compressed (64% reduction)
Over a session with 50 htmx requests, compression saves significant bandwidth.
#
Brotli vs Gzip
Brotli typically achieves 15-20% better compression than Gzip and is supported by all modern browsers. Configure both with Brotli as primary:
// In Program.cs - Add before builder.Build()
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"text/html",
"application/json",
"text/plain",
"text/css",
"application/javascript",
"text/javascript",
"application/xml",
"text/xml"
});
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Optimal;
});
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Optimal;
});
Enable compression early in the pipeline:
var app = builder.Build();
// Compression should be early in the pipeline
app.UseResponseCompression();
app.UseHttpsRedirection();
app.UseStaticFiles();
// ... rest of pipeline
#
23.2.4 Database Considerations
Production database configuration differs significantly from development.
#
Connection String Format for Azure SQL
Azure SQL connection strings include additional parameters:
Server=tcp:chinook-server.database.windows.net,1433;
Initial Catalog=ChinookDb;
Persist Security Info=False;
User ID=chinook-admin;
Password={your_password};
MultipleActiveResultSets=False;
Encrypt=True;
TrustServerCertificate=False;
Connection Timeout=30;
#
Key Vault References
Instead of storing connection strings directly in App Settings, reference Key Vault:
@Microsoft.KeyVault(SecretUri=https://chinook-vault.vault.azure.net/secrets/ChinookConnection/)
#
Production DbContext Configuration
Configure Entity Framework for production workloads:
// In Program.cs
builder.Services.AddDbContext<ChinookContext>((serviceProvider, options) =>
{
var connectionString = builder.Configuration.GetConnectionString("ChinookConnection");
options.UseSqlServer(connectionString, sqlOptions =>
{
// Connection resiliency for transient failures
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
// Command timeout for long-running queries
sqlOptions.CommandTimeout(30);
});
// Production optimizations
if (builder.Environment.IsProduction())
{
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
});
#
Migration Strategy
For production deployments, generate SQL scripts rather than running migrations directly:
# Generate migration script
dotnet ef migrations script --idempotent --output migrations.sql
# Review the script before applying
# Apply via Azure Portal Query Editor or sqlcmd during deployment
The --idempotent flag generates scripts that check if migrations have already been applied, making them safe to run multiple times.
#
Complete Production Program.cs
Here's the complete Program.cs with all production configurations:
using System.IO.Compression;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using ChinookDashboard.Data;
using ChinookDashboard.Services;
var builder = WebApplication.CreateBuilder(args);
// ===== Services Configuration =====
// Response Compression
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"text/html",
"application/json",
"text/plain",
"text/css",
"application/javascript"
});
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Optimal;
});
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Optimal;
});
// Database Context
builder.Services.AddDbContext<ChinookContext>((serviceProvider, options) =>
{
var connectionString = builder.Configuration.GetConnectionString("ChinookConnection");
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
sqlOptions.CommandTimeout(30);
});
if (builder.Environment.IsProduction())
{
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
});
// Application Services
builder.Services.AddScoped<IArtistService, ArtistService>();
builder.Services.AddScoped<IAlbumService, AlbumService>();
builder.Services.AddScoped<ITrackService, TrackService>();
// Razor Pages
builder.Services.AddRazorPages();
// Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<ChinookContext>("database");
// Anti-forgery
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "RequestVerificationToken";
});
var app = builder.Build();
// ===== Middleware Pipeline =====
// Response compression (early in pipeline)
app.UseResponseCompression();
// Error handling
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
// Static files with caching
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
var cacheMaxAge = builder.Configuration.GetValue<int>("Caching:StaticFilesCacheMaxAge", 86400);
ctx.Context.Response.Headers.Append("Cache-Control", $"public, max-age={cacheMaxAge}");
}
});
app.UseRouting();
app.UseAuthorization();
// Health check endpoint
app.MapHealthChecks("/health");
app.MapRazorPages();
app.Run();
#
23.3 Setting Up Azure Resources
With the application prepared, create the Azure infrastructure to host it.
#
23.3.1 Azure App Service Setup
Azure App Service provides managed hosting for web applications. Choose the right tier based on your needs:
For production htmx applications, P1v3 provides deployment slots for zero-downtime deployments and auto-scaling for traffic spikes.
#
Azure CLI Setup Script
Create a script to provision all resources:
infrastructure/azure-setup.sh
#!/bin/bash
# ============================================
# Azure Resource Setup for Chinook Dashboard
# ============================================
# Configuration - Change these values
RESOURCE_GROUP="chinook-rg"
LOCATION="eastus"
APP_SERVICE_PLAN="chinook-plan"
WEB_APP_NAME="chinook-dashboard" # Must be globally unique
SQL_SERVER_NAME="chinook-sql" # Must be globally unique
SQL_DATABASE_NAME="ChinookDb"
SQL_ADMIN_USER="chinook-admin"
KEY_VAULT_NAME="chinook-vault" # Must be globally unique
# Prompt for SQL password (don't hardcode!)
echo "Enter SQL Admin Password (min 8 chars, requires uppercase, lowercase, number):"
read -s SQL_ADMIN_PASSWORD
echo ""
echo "Starting Azure resource provisioning..."
echo "========================================"
# ============================================
# 1. Resource Group
# ============================================
echo "Creating resource group..."
az group create \
--name $RESOURCE_GROUP \
--location $LOCATION \
--output none
echo "✓ Resource group created: $RESOURCE_GROUP"
# ============================================
# 2. App Service Plan
# ============================================
echo "Creating App Service Plan..."
az appservice plan create \
--name $APP_SERVICE_PLAN \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--sku P1V3 \
--is-linux false \
--output none
echo "✓ App Service Plan created: $APP_SERVICE_PLAN (P1V3)"
# ============================================
# 3. Web App
# ============================================
echo "Creating Web App..."
az webapp create \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--plan $APP_SERVICE_PLAN \
--runtime "dotnet:8" \
--output none
echo "✓ Web App created: $WEB_APP_NAME"
# Configure Web App settings
echo "Configuring Web App settings..."
az webapp config set \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--always-on true \
--http20-enabled true \
--min-tls-version 1.2 \
--ftps-state Disabled \
--output none
# Enable system-assigned managed identity
az webapp identity assign \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--output none
echo "✓ Web App configured with Always On, HTTP/2, TLS 1.2"
# ============================================
# 4. Deployment Slot (Staging)
# ============================================
echo "Creating staging deployment slot..."
az webapp deployment slot create \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--slot staging \
--output none
# Configure staging slot
az webapp config set \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--slot staging \
--always-on true \
--output none
echo "✓ Staging slot created"
# ============================================
# 5. SQL Server
# ============================================
echo "Creating SQL Server..."
az sql server create \
--name $SQL_SERVER_NAME \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--admin-user $SQL_ADMIN_USER \
--admin-password "$SQL_ADMIN_PASSWORD" \
--output none
echo "✓ SQL Server created: $SQL_SERVER_NAME"
# Configure firewall to allow Azure services
echo "Configuring SQL Server firewall..."
az sql server firewall-rule create \
--server $SQL_SERVER_NAME \
--resource-group $RESOURCE_GROUP \
--name AllowAzureServices \
--start-ip-address 0.0.0.0 \
--end-ip-address 0.0.0.0 \
--output none
echo "✓ SQL Server firewall configured for Azure services"
# ============================================
# 6. SQL Database
# ============================================
echo "Creating SQL Database..."
az sql db create \
--name $SQL_DATABASE_NAME \
--resource-group $RESOURCE_GROUP \
--server $SQL_SERVER_NAME \
--service-objective S0 \
--backup-storage-redundancy Local \
--output none
echo "✓ SQL Database created: $SQL_DATABASE_NAME (Standard S0)"
# ============================================
# 7. Key Vault
# ============================================
echo "Creating Key Vault..."
az keyvault create \
--name $KEY_VAULT_NAME \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--enable-rbac-authorization false \
--output none
echo "✓ Key Vault created: $KEY_VAULT_NAME"
# Get Web App's managed identity
WEB_APP_IDENTITY=$(az webapp identity show \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--query principalId \
--output tsv)
# Grant Web App access to Key Vault secrets
az keyvault set-policy \
--name $KEY_VAULT_NAME \
--object-id $WEB_APP_IDENTITY \
--secret-permissions get list \
--output none
echo "✓ Key Vault access policy set for Web App"
# ============================================
# 8. Store Connection String in Key Vault
# ============================================
echo "Storing connection string in Key Vault..."
CONNECTION_STRING="Server=tcp:${SQL_SERVER_NAME}.database.windows.net,1433;Initial Catalog=${SQL_DATABASE_NAME};Persist Security Info=False;User ID=${SQL_ADMIN_USER};Password=${SQL_ADMIN_PASSWORD};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"
az keyvault secret set \
--vault-name $KEY_VAULT_NAME \
--name "ChinookConnection" \
--value "$CONNECTION_STRING" \
--output none
echo "✓ Connection string stored in Key Vault"
# ============================================
# 9. Configure Web App Connection String
# ============================================
echo "Configuring Web App to use Key Vault reference..."
KEY_VAULT_REFERENCE="@Microsoft.KeyVault(SecretUri=https://${KEY_VAULT_NAME}.vault.azure.net/secrets/ChinookConnection/)"
az webapp config connection-string set \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--connection-string-type SQLAzure \
--settings ChinookConnection="$KEY_VAULT_REFERENCE" \
--output none
# Also set for staging slot
az webapp config connection-string set \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--slot staging \
--connection-string-type SQLAzure \
--settings ChinookConnection="$KEY_VAULT_REFERENCE" \
--output none
echo "✓ Web App configured with Key Vault reference"
# ============================================
# 10. Configure App Settings
# ============================================
echo "Setting application configuration..."
az webapp config appsettings set \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--settings \
ASPNETCORE_ENVIRONMENT=Production \
Logging__LogLevel__Default=Information \
Logging__LogLevel__Microsoft.AspNetCore=Warning \
--output none
echo "✓ App settings configured"
# ============================================
# Summary
# ============================================
echo ""
echo "========================================"
echo "Azure Resource Setup Complete!"
echo "========================================"
echo ""
echo "Resources created:"
echo " • Resource Group: $RESOURCE_GROUP"
echo " • App Service Plan: $APP_SERVICE_PLAN (P1V3)"
echo " • Web App: $WEB_APP_NAME"
echo " • Staging Slot: $WEB_APP_NAME/staging"
echo " • SQL Server: $SQL_SERVER_NAME.database.windows.net"
echo " • SQL Database: $SQL_DATABASE_NAME"
echo " • Key Vault: $KEY_VAULT_NAME"
echo ""
echo "Web App URLs:"
echo " • Production: https://${WEB_APP_NAME}.azurewebsites.net"
echo " • Staging: https://${WEB_APP_NAME}-staging.azurewebsites.net"
echo ""
echo "Next steps:"
echo " 1. Run database migrations against $SQL_SERVER_NAME"
echo " 2. Configure GitHub Actions with deployment credentials"
echo " 3. Deploy your application"
echo ""
Make the script executable and run it:
chmod +x infrastructure/azure-setup.sh
./infrastructure/azure-setup.sh
#
23.3.2 Azure SQL Database Setup
The setup script creates the database, but you may need additional configuration.
#
Connection String Format
The complete connection string for Azure SQL:
Server=tcp:chinook-sql.database.windows.net,1433;
Initial Catalog=ChinookDb;
Persist Security Info=False;
User ID=chinook-admin;
Password={your_password};
MultipleActiveResultSets=False;
Encrypt=True;
TrustServerCertificate=False;
Connection Timeout=30;
#
Firewall Configuration
The script allows Azure services. To connect from your local machine for migrations:
# Get your current IP
MY_IP=$(curl -s ifconfig.me)
# Add firewall rule
az sql server firewall-rule create \
--server chinook-sql \
--resource-group chinook-rg \
--name MyLocalIP \
--start-ip-address $MY_IP \
--end-ip-address $MY_IP
#
Running Migrations
Apply migrations to the production database:
# Generate idempotent script
dotnet ef migrations script --idempotent --output migrations.sql
# Apply using sqlcmd
sqlcmd -S chinook-sql.database.windows.net \
-d ChinookDb \
-U chinook-admin \
-P 'YourPassword' \
-i migrations.sql
Or use the Azure Portal Query Editor for smaller scripts.
#
23.3.3 Application Settings and Connection Strings
Azure App Settings override appsettings.json values. Configure them via CLI or Portal.
#
Setting App Settings via CLI
# Set multiple settings at once
az webapp config appsettings set \
--name chinook-dashboard \
--resource-group chinook-rg \
--settings \
ASPNETCORE_ENVIRONMENT=Production \
ApplicationInsights__ConnectionString="InstrumentationKey=xxx" \
Htmx__Timeout=30000 \
FeatureFlags__EnableSwagger=false
#
Key Vault References
For sensitive values, use Key Vault references instead of plain text:
# Store secret in Key Vault
az keyvault secret set \
--vault-name chinook-vault \
--name "AppInsightsKey" \
--value "your-instrumentation-key"
# Reference in App Settings
az webapp config appsettings set \
--name chinook-dashboard \
--resource-group chinook-rg \
--settings \
ApplicationInsights__ConnectionString="@Microsoft.KeyVault(SecretUri=https://chinook-vault.vault.azure.net/secrets/AppInsightsKey/)"
#
Slot Settings
Some settings should differ between production and staging slots. Mark them as slot settings:
# Make setting "sticky" to its slot
az webapp config appsettings set \
--name chinook-dashboard \
--resource-group chinook-rg \
--slot-settings \
IsStaging=false
az webapp config appsettings set \
--name chinook-dashboard \
--resource-group chinook-rg \
--slot staging \
--slot-settings \
IsStaging=true
#
23.3.4 Custom Domain and SSL
Add a custom domain to your App Service.
#
Prerequisites
- Own a domain (e.g.,
chinook.example.com) - Access to DNS management for that domain
#
DNS Configuration
Add a CNAME record pointing to your App Service:
Or for apex domains (example.com without subdomain), use an A record with Azure's IP and a TXT record for verification.
#
Complete Custom Domain Setup Script
infrastructure/setup-domain.sh
#!/bin/bash
# Configuration
RESOURCE_GROUP="chinook-rg"
WEB_APP_NAME="chinook-dashboard"
CUSTOM_DOMAIN="chinook.example.com"
echo "Setting up custom domain: $CUSTOM_DOMAIN"
echo "========================================="
# ============================================
# 1. Add Custom Domain
# ============================================
echo "Adding custom domain to Web App..."
echo "NOTE: Ensure DNS CNAME record exists first!"
echo " CNAME: chinook -> ${WEB_APP_NAME}.azurewebsites.net"
echo ""
read -p "DNS configured? (y/n): " DNS_READY
if [ "$DNS_READY" != "y" ]; then
echo "Please configure DNS first, then re-run this script."
exit 1
fi
az webapp config hostname add \
--webapp-name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--hostname $CUSTOM_DOMAIN \
--output none
echo "✓ Custom domain added"
# ============================================
# 2. Create Managed SSL Certificate
# ============================================
echo "Creating managed SSL certificate..."
az webapp config ssl create \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--hostname $CUSTOM_DOMAIN \
--output none
echo "✓ SSL certificate created (may take a few minutes to provision)"
# ============================================
# 3. Bind SSL Certificate
# ============================================
echo "Binding SSL certificate to domain..."
# Get the certificate thumbprint
THUMBPRINT=$(az webapp config ssl list \
--resource-group $RESOURCE_GROUP \
--query "[?subjectName=='$CUSTOM_DOMAIN'].thumbprint" \
--output tsv)
if [ -z "$THUMBPRINT" ]; then
echo "Certificate not ready yet. Check Azure Portal in a few minutes."
else
az webapp config ssl bind \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--certificate-thumbprint $THUMBPRINT \
--ssl-type SNI \
--output none
echo "✓ SSL certificate bound"
fi
# ============================================
# 4. Enforce HTTPS
# ============================================
echo "Enforcing HTTPS redirect..."
az webapp update \
--name $WEB_APP_NAME \
--resource-group $RESOURCE_GROUP \
--https-only true \
--output none
echo "✓ HTTPS-only mode enabled"
# ============================================
# Summary
# ============================================
echo ""
echo "========================================"
echo "Custom Domain Setup Complete!"
echo "========================================"
echo ""
echo "Your site is now available at:"
echo " https://$CUSTOM_DOMAIN"
echo ""
echo "Note: SSL certificate provisioning may take up to 15 minutes."
echo "Check the Azure Portal if the certificate doesn't appear immediately."
#
Enforcing HTTPS in Application
Even with Azure's HTTPS-only setting, add middleware for defense in depth:
// In Program.cs
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();
Configure HSTS properly:
builder.Services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(365);
});
With the Azure resources provisioned and the application configured for production, you're ready to set up automated deployments with GitHub Actions in the next section.
#
23.4 GitHub Actions CI/CD Pipeline
Automated deployment pipelines eliminate manual deployment steps, reduce errors, and enable rapid iteration. GitHub Actions integrates directly with your repository to build, test, and deploy on every commit.
#
23.4.1 Understanding GitHub Actions
GitHub Actions uses YAML files in the .github/workflows directory to define automation workflows.
#
Workflow Structure
name: Workflow Name # Display name in GitHub UI
on: # Triggers
push:
branches: [main]
pull_request:
branches: [main]
env: # Environment variables for all jobs
DOTNET_VERSION: '8.0.x'
jobs: # One or more jobs
build: # Job ID
runs-on: ubuntu-latest # Runner type
steps: # Sequential steps
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
#
Trigger Types
push: Runs when commits are pushed to specified branches
on:
push:
branches: [main, develop]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request: Runs when PRs target specified branches
on:
pull_request:
branches: [main]
workflow_dispatch: Allows manual triggering from GitHub UI
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
#
GitHub Secrets
Store sensitive values in repository or organization secrets:
- Navigate to repository Settings → Secrets and variables → Actions
- Add secrets like
AZURE_CREDENTIALS,SQL_CONNECTION_STRING - Reference in workflows:
${{ ERROR }}
#
Environments for Deployment Approvals
Create environments with protection rules:
- Settings → Environments → New environment
- Add required reviewers for production deployments
- Reference in workflow:
environment: production
#
23.4.2 Basic Build and Test Workflow
Start with a workflow that builds and tests on every push and pull request.
.github/workflows/build-test.yml
name: Build and Test
on:
push:
branches: [main, develop]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/ISSUE_TEMPLATE/**'
pull_request:
branches: [main, develop]
env:
DOTNET_VERSION: '8.0.x'
SOLUTION_PATH: 'ChinookDashboard.sln'
TEST_PROJECT_PATH: 'ChinookDashboard.Tests/ChinookDashboard.Tests.csproj'
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ ERROR }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ ERROR }}-nuget-${{ ERROR }}
restore-keys: |
${{ ERROR }}-nuget-
- name: Restore dependencies
run: dotnet restore ${{ ERROR }}
- name: Build
run: dotnet build ${{ ERROR }} --no-restore --configuration Release
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: |
**/bin/Release/net8.0/
!**/bin/Release/net8.0/publish/
retention-days: 1
test-unit:
name: Unit Tests
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ ERROR }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ ERROR }}-nuget-${{ ERROR }}
restore-keys: |
${{ ERROR }}-nuget-
- name: Restore dependencies
run: dotnet restore ${{ ERROR }}
- name: Run unit tests
run: |
dotnet test ${{ ERROR }} \
--no-restore \
--configuration Release \
--filter "FullyQualifiedName~Unit" \
--logger "trx;LogFileName=unit-test-results.trx" \
--results-directory ./TestResults
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: unit-test-results
path: ./TestResults/*.trx
retention-days: 7
test-integration:
name: Integration Tests
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ ERROR }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ ERROR }}-nuget-${{ ERROR }}
restore-keys: |
${{ ERROR }}-nuget-
- name: Restore dependencies
run: dotnet restore ${{ ERROR }}
- name: Run integration tests
run: |
dotnet test ${{ ERROR }} \
--no-restore \
--configuration Release \
--filter "FullyQualifiedName~Integration" \
--logger "trx;LogFileName=integration-test-results.trx" \
--results-directory ./TestResults
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-test-results
path: ./TestResults/*.trx
retention-days: 7
test-browser:
name: Browser Tests
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ ERROR }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ ERROR }}-nuget-${{ ERROR }}
restore-keys: |
${{ ERROR }}-nuget-
- name: Restore dependencies
run: dotnet restore ${{ ERROR }}
- name: Build test project
run: dotnet build ${{ ERROR }} --no-restore --configuration Release
- name: Install Playwright browsers
run: pwsh ChinookDashboard.Tests/bin/Release/net8.0/playwright.ps1 install --with-deps chromium
- name: Run browser tests
run: |
dotnet test ${{ ERROR }} \
--no-build \
--configuration Release \
--filter "FullyQualifiedName~Browser" \
--logger "trx;LogFileName=browser-test-results.trx" \
--results-directory ./TestResults
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: browser-test-results
path: ./TestResults/*.trx
retention-days: 7
- name: Upload Playwright traces
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: '**/TestResults/Traces/'
retention-days: 7
#
23.4.3 Deployment Workflow
To deploy to Azure, create a service principal and store its credentials as a GitHub secret.
#
Creating Azure Service Principal
#!/bin/bash
# create-service-principal.sh
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
RESOURCE_GROUP="chinook-rg"
APP_NAME="chinook-dashboard"
# Create service principal with contributor access to resource group
az ad sp create-for-rbac \
--name "github-actions-$APP_NAME" \
--role contributor \
--scopes /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP \
--sdk-auth
# Output looks like:
# {
# "clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
# "clientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
# "subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
# "tenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
# ...
# }
#
# Copy this entire JSON output and save as GitHub secret: AZURE_CREDENTIALS
Save the JSON output as a GitHub secret named AZURE_CREDENTIALS.
#
Basic Deployment Workflow
.github/workflows/deploy.yml
name: Deploy to Azure
on:
workflow_dispatch:
inputs:
environment:
description: 'Deployment target'
required: true
default: 'staging'
type: choice
options:
- staging
- production
env:
DOTNET_VERSION: '8.0.x'
AZURE_WEBAPP_NAME: chinook-dashboard
AZURE_RESOURCE_GROUP: chinook-rg
PROJECT_PATH: 'ChinookDashboard/ChinookDashboard.csproj'
jobs:
deploy:
name: Deploy to ${{ ERROR }}
runs-on: ubuntu-latest
environment: ${{ ERROR }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ ERROR }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ ERROR }}-nuget-${{ ERROR }}
restore-keys: |
${{ ERROR }}-nuget-
- name: Restore dependencies
run: dotnet restore ${{ ERROR }}
- name: Build
run: dotnet build ${{ ERROR }} --no-restore --configuration Release
- name: Publish
run: |
dotnet publish ${{ ERROR }} \
--no-build \
--configuration Release \
--output ./publish
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ ERROR }}
- name: Deploy to Staging Slot
if: github.event.inputs.environment == 'staging'
uses: azure/webapps-deploy@v3
with:
app-name: ${{ ERROR }}
slot-name: staging
package: ./publish
- name: Deploy to Production
if: github.event.inputs.environment == 'production'
uses: azure/webapps-deploy@v3
with:
app-name: ${{ ERROR }}
package: ./publish
- name: Logout from Azure
if: always()
run: az logout
#
23.4.4 Complete CI/CD Pipeline
Combine building, testing, and deployment into a single pipeline with proper stage dependencies and approvals.
.github/workflows/azure-deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
branches: [main]
workflow_dispatch:
env:
DOTNET_VERSION: '8.0.x'
AZURE_WEBAPP_NAME: chinook-dashboard
AZURE_RESOURCE_GROUP: chinook-rg
PROJECT_PATH: 'ChinookDashboard/ChinookDashboard.csproj'
TEST_PROJECT_PATH: 'ChinookDashboard.Tests/ChinookDashboard.Tests.csproj'
# Prevent concurrent deployments to same environment
concurrency:
group: ${{ ERROR }}-${{ ERROR }}
cancel-in-progress: false
jobs:
# ==========================================
# BUILD
# ==========================================
build:
name: Build Application
runs-on: ubuntu-latest
outputs:
artifact-name: ${{ ERROR }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ ERROR }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ ERROR }}-nuget-${{ ERROR }}
restore-keys: |
${{ ERROR }}-nuget-
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Publish application
run: |
dotnet publish ${{ ERROR }} \
--no-build \
--configuration Release \
--output ./publish
- name: Set artifact name
id: set-artifact-name
run: echo "name=app-${{ ERROR }}-${{ ERROR }}" >> $GITHUB_OUTPUT
- name: Upload application artifact
uses: actions/upload-artifact@v4
with:
name: ${{ ERROR }}
path: ./publish
retention-days: 3
# ==========================================
# TEST
# ==========================================
test:
name: Run Tests
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ ERROR }}
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ ERROR }}-nuget-${{ ERROR }}
restore-keys: |
${{ ERROR }}-nuget-
- name: Restore test project
run: dotnet restore ${{ ERROR }}
- name: Build test project
run: dotnet build ${{ ERROR }} --no-restore --configuration Release
- name: Run unit tests
run: |
dotnet test ${{ ERROR }} \
--no-build \
--configuration Release \
--filter "FullyQualifiedName~Unit" \
--logger "trx;LogFileName=unit-tests.trx" \
--results-directory ./TestResults
- name: Run integration tests
run: |
dotnet test ${{ ERROR }} \
--no-build \
--configuration Release \
--filter "FullyQualifiedName~Integration" \
--logger "trx;LogFileName=integration-tests.trx" \
--results-directory ./TestResults
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: ./TestResults/*.trx
retention-days: 7
# ==========================================
# DEPLOY TO STAGING
# ==========================================
deploy-staging:
name: Deploy to Staging
needs: [build, test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://${{ ERROR }}-staging.azurewebsites.net
steps:
- name: Download application artifact
uses: actions/download-artifact@v4
with:
name: ${{ ERROR }}
path: ./publish
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ ERROR }}
- name: Deploy to staging slot
uses: azure/webapps-deploy@v3
with:
app-name: ${{ ERROR }}
slot-name: staging
package: ./publish
- name: Verify staging deployment
run: |
echo "Waiting for staging to be ready..."
sleep 30
HEALTH_URL="https://${{ ERROR }}-staging.azurewebsites.net/health"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL)
if [ $HTTP_STATUS -eq 200 ]; then
echo "✓ Staging deployment healthy"
else
echo "✗ Staging health check failed: HTTP $HTTP_STATUS"
exit 1
fi
- name: Logout from Azure
if: always()
run: az logout
# ==========================================
# DEPLOY TO PRODUCTION
# ==========================================
deploy-production:
name: Deploy to Production
needs: [build, deploy-staging]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://${{ ERROR }}.azurewebsites.net
steps:
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ ERROR }}
- name: Swap staging to production
run: |
az webapp deployment slot swap \
--name ${{ ERROR }} \
--resource-group ${{ ERROR }} \
--slot staging \
--target-slot production
- name: Verify production deployment
run: |
echo "Waiting for production to stabilize..."
sleep 30
HEALTH_URL="https://${{ ERROR }}.azurewebsites.net/health"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL)
if [ $HTTP_STATUS -eq 200 ]; then
echo "✓ Production deployment healthy"
else
echo "✗ Production health check failed: HTTP $HTTP_STATUS"
echo "Consider rolling back with slot swap"
exit 1
fi
- name: Logout from Azure
if: always()
run: az logout
# ==========================================
# ROLLBACK (Manual trigger only)
# ==========================================
rollback:
name: Rollback Production
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
environment:
name: production
steps:
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ ERROR }}
- name: Swap production back to staging
run: |
echo "Rolling back production..."
az webapp deployment slot swap \
--name ${{ ERROR }} \
--resource-group ${{ ERROR }} \
--slot production \
--target-slot staging
echo "✓ Rollback complete"
- name: Logout from Azure
if: always()
run: az logout
#
23.4.5 Database Migrations in CI/CD
Apply database migrations during deployment using generated SQL scripts.
#
Migration Step in Build Job
Add migration script generation to the build job:
# Add to the build job after 'Publish application' step
- name: Install EF Core tools
run: dotnet tool install --global dotnet-ef
- name: Generate migration script
run: |
dotnet ef migrations script \
--idempotent \
--project ${{ ERROR }} \
--output ./publish/migrations.sql
env:
# Use a dummy connection string for script generation
ConnectionStrings__ChinookConnection: "Server=.;Database=Dummy;Trusted_Connection=True;"
- name: Upload migration script
uses: actions/upload-artifact@v4
with:
name: migration-script
path: ./publish/migrations.sql
retention-days: 7
#
Migration Job
Add a separate job to apply migrations before deployment:
# Add after 'test' job, before 'deploy-staging'
migrate-staging:
name: Apply Migrations (Staging)
needs: [build, test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Download migration script
uses: actions/download-artifact@v4
with:
name: migration-script
path: ./migrations
- name: Login to Azure
uses: azure/login@v2
with:
creds: ${{ ERROR }}
- name: Apply migrations to staging database
run: |
# Get connection string from Key Vault
CONNECTION_STRING=$(az keyvault secret show \
--vault-name chinook-vault \
--name ChinookConnection \
--query value -o tsv)
# Extract server, database, user, password from connection string
SERVER=$(echo $CONNECTION_STRING | grep -oP 'Server=tcp:\K[^,]+')
DATABASE=$(echo $CONNECTION_STRING | grep -oP 'Initial Catalog=\K[^;]+')
USER=$(echo $CONNECTION_STRING | grep -oP 'User ID=\K[^;]+')
PASSWORD=$(echo $CONNECTION_STRING | grep -oP 'Password=\K[^;]+')
# Apply migrations using sqlcmd
sqlcmd -S $SERVER -d $DATABASE -U $USER -P "$PASSWORD" -i ./migrations/migrations.sql
echo "✓ Migrations applied successfully"
- name: Logout from Azure
if: always()
run: az logout
#
Rollback Considerations
For migration rollbacks, maintain down migration scripts or use point-in-time restore:
# Generate down migration script (if needed)
dotnet ef migrations script \
CurrentMigration \
PreviousMigration \
--output rollback.sql
# Or use Azure SQL point-in-time restore
az sql db restore \
--dest-name ChinookDb-Restored \
--name ChinookDb \
--resource-group chinook-rg \
--server chinook-sql \
--time "2024-01-15T10:00:00Z"
#
23.5 htmx-Specific Deployment Considerations
htmx applications have unique deployment considerations around caching, error handling, and health checks.
#
23.5.1 Caching Strategies for Partial Responses
Not all htmx responses should be cached. Consider the content type:
Cache these (static or slowly changing):
- Genre dropdown options
- Navigation menus
- Static lookup lists
- Rarely updated reference data
Don't cache these:
- User-specific content
- Search results
- Real-time data (stats, counts)
- Form submissions
- Anything with user context
#
HtmxResponseCachingMiddleware
Middleware/HtmxResponseCachingMiddleware.cs
using Microsoft.Extensions.Options;
namespace ChinookDashboard.Middleware;
public class HtmxCacheOptions
{
public Dictionary<string, CacheProfile> Profiles { get; set; } = new();
public int DefaultMaxAge { get; set; } = 0;
public bool VaryByHxRequest { get; set; } = true;
}
public class CacheProfile
{
public int MaxAge { get; set; }
public bool IsPrivate { get; set; }
public bool NoStore { get; set; }
public string[]? VaryByQueryKeys { get; set; }
}
public class HtmxResponseCachingMiddleware
{
private readonly RequestDelegate _next;
private readonly HtmxCacheOptions _options;
private readonly ILogger<HtmxResponseCachingMiddleware> _logger;
public HtmxResponseCachingMiddleware(
RequestDelegate next,
IOptions<HtmxCacheOptions> options,
ILogger<HtmxResponseCachingMiddleware> logger)
{
_next = next;
_options = options.Value;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Only process htmx requests
if (!IsHtmxRequest(context))
{
await _next(context);
return;
}
// Get cache profile for this handler
var profile = GetCacheProfile(context);
// Add Vary header for htmx requests
if (_options.VaryByHxRequest)
{
context.Response.Headers.Append("Vary", "HX-Request");
}
if (profile != null)
{
SetCacheHeaders(context, profile);
}
else
{
// Default: no caching for htmx responses
SetNoCacheHeaders(context);
}
await _next(context);
}
private static bool IsHtmxRequest(HttpContext context)
{
return context.Request.Headers.ContainsKey("HX-Request");
}
private CacheProfile? GetCacheProfile(HttpContext context)
{
var path = context.Request.Path.Value?.ToLower() ?? "";
var handler = context.Request.Query["handler"].FirstOrDefault()?.ToLower();
// Check for specific handler profiles
if (!string.IsNullOrEmpty(handler))
{
var key = $"{path}:{handler}";
if (_options.Profiles.TryGetValue(key, out var profile))
{
return profile;
}
}
// Check for path-level profiles
if (_options.Profiles.TryGetValue(path, out var pathProfile))
{
return pathProfile;
}
return null;
}
private void SetCacheHeaders(HttpContext context, CacheProfile profile)
{
if (profile.NoStore)
{
SetNoCacheHeaders(context);
return;
}
var cacheControl = profile.IsPrivate ? "private" : "public";
cacheControl += $", max-age={profile.MaxAge}";
context.Response.Headers["Cache-Control"] = cacheControl;
if (profile.VaryByQueryKeys?.Length > 0)
{
context.Response.Headers.Append("Vary", string.Join(", ", profile.VaryByQueryKeys));
}
_logger.LogDebug(
"Set cache headers for htmx request: {Path}, Cache-Control: {CacheControl}",
context.Request.Path,
cacheControl);
}
private static void SetNoCacheHeaders(HttpContext context)
{
context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
context.Response.Headers["Pragma"] = "no-cache";
}
}
public static class HtmxResponseCachingExtensions
{
public static IServiceCollection AddHtmxResponseCaching(
this IServiceCollection services,
Action<HtmxCacheOptions> configure)
{
services.Configure(configure);
return services;
}
public static IApplicationBuilder UseHtmxResponseCaching(
this IApplicationBuilder app)
{
return app.UseMiddleware<HtmxResponseCachingMiddleware>();
}
}
Configuration in Program.cs:
// Configure htmx caching profiles
builder.Services.AddHtmxResponseCaching(options =>
{
options.VaryByHxRequest = true;
options.DefaultMaxAge = 0;
// Cache genre list for 1 hour
options.Profiles["/artists:genrelist"] = new CacheProfile
{
MaxAge = 3600,
IsPrivate = false
};
// Cache navigation for 5 minutes
options.Profiles["/shared:navigation"] = new CacheProfile
{
MaxAge = 300,
IsPrivate = false
};
// Don't cache search results
options.Profiles["/artists:list"] = new CacheProfile
{
NoStore = true
};
});
// In middleware pipeline (after routing, before endpoints)
app.UseHtmxResponseCaching();
#
23.5.2 CDN Configuration for Static Assets
Serve static assets from Azure CDN for better global performance.
#
Azure CDN Setup Script
infrastructure/setup-cdn.sh
#!/bin/bash
# Configuration
RESOURCE_GROUP="chinook-rg"
CDN_PROFILE_NAME="chinook-cdn"
CDN_ENDPOINT_NAME="chinook-assets"
WEB_APP_NAME="chinook-dashboard"
LOCATION="eastus"
echo "Setting up Azure CDN..."
# Create CDN profile
az cdn profile create \
--name $CDN_PROFILE_NAME \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--sku Standard_Microsoft \
--output none
echo "✓ CDN profile created"
# Create CDN endpoint pointing to App Service
az cdn endpoint create \
--name $CDN_ENDPOINT_NAME \
--profile-name $CDN_PROFILE_NAME \
--resource-group $RESOURCE_GROUP \
--origin "${WEB_APP_NAME}.azurewebsites.net" \
--origin-host-header "${WEB_APP_NAME}.azurewebsites.net" \
--enable-compression true \
--content-types-to-compress \
"text/html" \
"text/css" \
"application/javascript" \
"text/javascript" \
"application/json" \
--output none
echo "✓ CDN endpoint created"
# Add caching rules
az cdn endpoint rule add \
--name $CDN_ENDPOINT_NAME \
--profile-name $CDN_PROFILE_NAME \
--resource-group $RESOURCE_GROUP \
--order 1 \
--rule-name "CacheStaticAssets" \
--match-variable UrlFileExtension \
--operator Contains \
--match-values "js" "css" "woff2" "woff" "ttf" \
--action-name CacheExpiration \
--cache-behavior Override \
--cache-duration "365.00:00:00" \
--output none
echo "✓ Caching rule added for static assets"
# Add rule to bypass cache for htmx requests
az cdn endpoint rule add \
--name $CDN_ENDPOINT_NAME \
--profile-name $CDN_PROFILE_NAME \
--resource-group $RESOURCE_GROUP \
--order 2 \
--rule-name "BypassHtmxRequests" \
--match-variable RequestHeader \
--selector "HX-Request" \
--operator Any \
--action-name CacheExpiration \
--cache-behavior BypassCache \
--output none
echo "✓ Bypass rule added for htmx requests"
# Output CDN URL
CDN_URL="https://${CDN_ENDPOINT_NAME}.azureedge.net"
echo ""
echo "CDN setup complete!"
echo "CDN URL: $CDN_URL"
echo ""
echo "Update your application to use CDN for static assets:"
echo " <script src=\"$CDN_URL/lib/htmx/htmx.min.js\"></script>"
#
Cache Invalidation
Purge CDN cache after deployments:
# Purge specific paths
az cdn endpoint purge \
--name chinook-assets \
--profile-name chinook-cdn \
--resource-group chinook-rg \
--content-paths "/css/*" "/js/*" "/lib/*"
# Or purge everything
az cdn endpoint purge \
--name chinook-assets \
--profile-name chinook-cdn \
--resource-group chinook-rg \
--content-paths "/*"
Add to deployment workflow:
- name: Purge CDN cache
run: |
az cdn endpoint purge \
--name chinook-assets \
--profile-name chinook-cdn \
--resource-group ${{ ERROR }} \
--content-paths "/css/*" "/js/*" "/lib/*" \
--no-wait
#
23.5.3 Health Checks and Monitoring
Health checks enable Azure to detect unhealthy instances and route traffic appropriately.
#
DatabaseHealthCheck Implementation
Health/DatabaseHealthCheck.cs
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.EntityFrameworkCore;
using ChinookDashboard.Data;
namespace ChinookDashboard.Health;
public class DatabaseHealthCheck : IHealthCheck
{
private readonly ChinookContext _context;
private readonly ILogger<DatabaseHealthCheck> _logger;
public DatabaseHealthCheck(
ChinookContext context,
ILogger<DatabaseHealthCheck> logger)
{
_context = context;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// Simple connectivity check
var canConnect = await _context.Database.CanConnectAsync(cancellationToken);
if (!canConnect)
{
_logger.LogWarning("Database health check failed: Cannot connect");
return HealthCheckResult.Unhealthy("Cannot connect to database");
}
// Query check - verify we can read data
var artistCount = await _context.Artists
.CountAsync(cancellationToken);
var data = new Dictionary<string, object>
{
{ "artistCount", artistCount },
{ "timestamp", DateTime.UtcNow }
};
_logger.LogDebug("Database health check passed: {ArtistCount} artists", artistCount);
return HealthCheckResult.Healthy("Database is responsive", data);
}
catch (Exception ex)
{
_logger.LogError(ex, "Database health check exception");
return HealthCheckResult.Unhealthy(
"Database check failed",
exception: ex);
}
}
}
#
Complete Health Check Registration
Health/HealthCheckExtensions.cs
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text.Json;
namespace ChinookDashboard.Health;
public static class HealthCheckExtensions
{
public static IServiceCollection AddChinookHealthChecks(
this IServiceCollection services)
{
services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>(
"database",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "db", "sql" })
.AddCheck("self", () => HealthCheckResult.Healthy("Application is running"),
tags: new[] { "self" });
return services;
}
public static IApplicationBuilder UseChinookHealthChecks(
this IApplicationBuilder app)
{
// Simple health endpoint for load balancers
app.UseHealthChecks("/health", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("self"),
ResponseWriter = WriteMinimalResponse
});
// Detailed health endpoint for monitoring
app.UseHealthChecks("/health/detail", new HealthCheckOptions
{
ResponseWriter = WriteDetailedResponse
});
// htmx-compatible health endpoint
app.UseHealthChecks("/health/htmx", new HealthCheckOptions
{
ResponseWriter = WriteHtmxResponse
});
return app;
}
private static Task WriteMinimalResponse(
HttpContext context,
HealthReport report)
{
context.Response.ContentType = "text/plain";
return context.Response.WriteAsync(report.Status.ToString());
}
private static async Task WriteDetailedResponse(
HttpContext context,
HealthReport report)
{
context.Response.ContentType = "application/json";
var response = new
{
status = report.Status.ToString(),
duration = report.TotalDuration.TotalMilliseconds,
timestamp = DateTime.UtcNow,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
duration = e.Value.Duration.TotalMilliseconds,
description = e.Value.Description,
data = e.Value.Data,
exception = e.Value.Exception?.Message
})
};
await context.Response.WriteAsync(
JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
}));
}
private static async Task WriteHtmxResponse(
HttpContext context,
HealthReport report)
{
context.Response.ContentType = "text/html";
var statusClass = report.Status switch
{
HealthStatus.Healthy => "text-green-600",
HealthStatus.Degraded => "text-yellow-600",
_ => "text-red-600"
};
var html = $@"
<div id=""health-status"" class=""p-4 rounded border"">
<div class=""flex items-center gap-2 mb-2"">
<span class=""font-bold {statusClass}"">{report.Status}</span>
<span class=""text-gray-500 text-sm"">({report.TotalDuration.TotalMilliseconds:F0}ms)</span>
</div>
<ul class=""text-sm space-y-1"">
{string.Join("\n", report.Entries.Select(e => $@"
<li class=""flex justify-between"">
<span>{e.Key}</span>
<span class=""{GetStatusClass(e.Value.Status)}"">{e.Value.Status}</span>
</li>"))}
</ul>
<div class=""text-xs text-gray-400 mt-2"">
Last checked: {DateTime.UtcNow:HH:mm:ss} UTC
</div>
</div>";
await context.Response.WriteAsync(html);
}
private static string GetStatusClass(HealthStatus status) => status switch
{
HealthStatus.Healthy => "text-green-600",
HealthStatus.Degraded => "text-yellow-600",
_ => "text-red-600"
};
}
Registration in Program.cs:
// Services
builder.Services.AddChinookHealthChecks();
// Middleware (after routing)
app.UseChinookHealthChecks();
#
23.5.4 Error Handling in Production
Production error handling should return appropriate responses for htmx requests versus full page requests.
#
ProductionExceptionMiddleware
Middleware/ProductionExceptionMiddleware.cs
using System.Net;
namespace ChinookDashboard.Middleware;
public class ProductionExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ProductionExceptionMiddleware> _logger;
private readonly IWebHostEnvironment _environment;
public ProductionExceptionMiddleware(
RequestDelegate next,
ILogger<ProductionExceptionMiddleware> logger,
IWebHostEnvironment environment)
{
_next = next;
_logger = logger;
_environment = environment;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
// Handle status code errors (404, etc.)
if (context.Response.StatusCode >= 400 && !context.Response.HasStarted)
{
await HandleStatusCodeAsync(context);
}
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var requestId = context.TraceIdentifier;
var isHtmxRequest = context.Request.Headers.ContainsKey("HX-Request");
_logger.LogError(
exception,
"Unhandled exception. RequestId: {RequestId}, Path: {Path}, IsHtmx: {IsHtmx}",
requestId,
context.Request.Path,
isHtmxRequest);
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
if (isHtmxRequest)
{
await WriteHtmxErrorAsync(context, 500, requestId);
}
else
{
await WriteFullPageErrorAsync(context, 500, requestId);
}
}
private async Task HandleStatusCodeAsync(HttpContext context)
{
var isHtmxRequest = context.Request.Headers.ContainsKey("HX-Request");
var statusCode = context.Response.StatusCode;
var requestId = context.TraceIdentifier;
if (isHtmxRequest)
{
await WriteHtmxErrorAsync(context, statusCode, requestId);
}
else if (statusCode == 404)
{
await WriteFullPageErrorAsync(context, 404, requestId);
}
}
private async Task WriteHtmxErrorAsync(
HttpContext context,
int statusCode,
string requestId)
{
context.Response.ContentType = "text/html; charset=utf-8";
// Set htmx headers to show error in appropriate location
context.Response.Headers["HX-Retarget"] = "#error-container";
context.Response.Headers["HX-Reswap"] = "innerHTML";
var (title, message) = GetErrorContent(statusCode);
var showDetails = _environment.IsDevelopment();
var html = $@"
<div class=""bg-red-50 border border-red-200 rounded-lg p-4 my-4"" role=""alert"">
<div class=""flex items-start gap-3"">
<svg class=""w-5 h-5 text-red-600 mt-0.5"" fill=""currentColor"" viewBox=""0 0 20 20"">
<path fill-rule=""evenodd"" d=""M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"" clip-rule=""evenodd""/>
</svg>
<div class=""flex-1"">
<h3 class=""font-medium text-red-800"">{title}</h3>
<p class=""text-sm text-red-700 mt-1"">{message}</p>
{(showDetails ? $"<p class=\"text-xs text-red-500 mt-2\">Request ID: {requestId}</p>" : "")}
</div>
<button type=""button""
class=""text-red-500 hover:text-red-700""
onclick=""this.closest('[role=alert]').remove()"">
<svg class=""w-4 h-4"" fill=""currentColor"" viewBox=""0 0 20 20"">
<path fill-rule=""evenodd"" d=""M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"" clip-rule=""evenodd""/>
</svg>
</button>
</div>
</div>";
await context.Response.WriteAsync(html);
}
private async Task WriteFullPageErrorAsync(
HttpContext context,
int statusCode,
string requestId)
{
context.Response.ContentType = "text/html; charset=utf-8";
var (title, message) = GetErrorContent(statusCode);
var showDetails = _environment.IsDevelopment();
var html = $@"
<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"" />
<title>{title} - Chinook Dashboard</title>
<link rel=""stylesheet"" href=""/css/site.min.css"" />
</head>
<body class=""bg-gray-50 min-h-screen flex items-center justify-center"">
<div class=""max-w-md mx-auto text-center p-8"">
<div class=""text-6xl font-bold text-gray-300 mb-4"">{statusCode}</div>
<h1 class=""text-2xl font-bold text-gray-800 mb-2"">{title}</h1>
<p class=""text-gray-600 mb-6"">{message}</p>
<a href=""/"" class=""inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"">
<svg class=""w-4 h-4"" fill=""none"" stroke=""currentColor"" viewBox=""0 0 24 24"">
<path stroke-linecap=""round"" stroke-linejoin=""round"" stroke-width=""2"" d=""M10 19l-7-7m0 0l7-7m-7 7h18""/>
</svg>
Back to Home
</a>
{(showDetails ? $"<p class=\"text-xs text-gray-400 mt-8\">Request ID: {requestId}</p>" : "")}
</div>
</body>
</html>";
await context.Response.WriteAsync(html);
}
private static (string Title, string Message) GetErrorContent(int statusCode)
{
return statusCode switch
{
400 => ("Bad Request", "The request could not be understood. Please check your input and try again."),
401 => ("Unauthorized", "You need to sign in to access this resource."),
403 => ("Forbidden", "You don't have permission to access this resource."),
404 => ("Not Found", "The page you're looking for doesn't exist or has been moved."),
408 => ("Request Timeout", "The request took too long to complete. Please try again."),
500 => ("Server Error", "Something went wrong on our end. We've been notified and are working on it."),
502 => ("Bad Gateway", "The server received an invalid response. Please try again later."),
503 => ("Service Unavailable", "The service is temporarily unavailable. Please try again later."),
_ => ("Error", "An unexpected error occurred. Please try again.")
};
}
}
public static class ProductionExceptionMiddlewareExtensions
{
public static IApplicationBuilder UseProductionExceptionHandler(
this IApplicationBuilder app)
{
return app.UseMiddleware<ProductionExceptionMiddleware>();
}
}
#
Error Partial Views
For more complex error templates, use Razor partial views:
Pages/Shared/_Error404.cshtml
@{
Layout = null;
}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4" role="alert">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<div>
<h3 class="font-medium text-yellow-800">Not Found</h3>
<p class="text-sm text-yellow-700">The requested resource could not be found.</p>
</div>
</div>
</div>
Pages/Shared/_Error500.cshtml
@{
Layout = null;
}
<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<div>
<h3 class="font-medium text-red-800">Server Error</h3>
<p class="text-sm text-red-700">Something went wrong. Please try again later.</p>
</div>
</div>
</div>
Registration in Program.cs:
// Use production exception handler early in pipeline
if (!app.Environment.IsDevelopment())
{
app.UseProductionExceptionHandler();
}
else
{
app.UseDeveloperExceptionPage();
}
#
Error Container in Layout
Add an error container to your layout for htmx error responses:
<!-- In _Layout.cshtml, before main content -->
<div id="error-container" class="container mx-auto px-4">
<!-- htmx errors will be swapped here -->
</div>
<main class="container mx-auto px-4 py-8">
@RenderBody()
</main>
This ensures htmx error responses have a consistent location to display, while maintaining the ability to show inline errors when appropriate.
#
23.6 Monitoring and Troubleshooting
Production applications need visibility into performance, errors, and usage patterns. Azure Application Insights provides deep monitoring for ASP.NET Core applications.
#
23.6.1 Application Insights Setup
Install the Application Insights package:
dotnet add package Microsoft.ApplicationInsights.AspNetCore
Basic Configuration in Program.cs:
// Add Application Insights
builder.Services.AddApplicationInsightsTelemetry(options =>
{
options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
options.EnableAdaptiveSampling = true;
options.EnableQuickPulseMetricStream = true; // Live metrics
});
#
HtmxTelemetryMiddleware
Track htmx-specific properties for better insights into partial request patterns.
Middleware/HtmxTelemetryMiddleware.cs
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.DataContracts;
using System.Diagnostics;
namespace ChinookDashboard.Middleware;
public class HtmxTelemetryMiddleware
{
private readonly RequestDelegate _next;
private readonly TelemetryClient _telemetryClient;
private readonly ILogger<HtmxTelemetryMiddleware> _logger;
public HtmxTelemetryMiddleware(
RequestDelegate next,
TelemetryClient telemetryClient,
ILogger<HtmxTelemetryMiddleware> logger)
{
_next = next;
_telemetryClient = telemetryClient;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var isHtmxRequest = context.Request.Headers.ContainsKey("HX-Request");
var stopwatch = Stopwatch.StartNew();
// Extract htmx headers
var htmxTarget = context.Request.Headers["HX-Target"].FirstOrDefault();
var htmxTrigger = context.Request.Headers["HX-Trigger"].FirstOrDefault();
var htmxTriggerName = context.Request.Headers["HX-Trigger-Name"].FirstOrDefault();
var htmxCurrentUrl = context.Request.Headers["HX-Current-URL"].FirstOrDefault();
var handler = context.Request.Query["handler"].FirstOrDefault();
// Add properties to the current request telemetry
var requestTelemetry = context.Features.Get<RequestTelemetry>();
if (requestTelemetry != null)
{
requestTelemetry.Properties["IsHtmxRequest"] = isHtmxRequest.ToString();
requestTelemetry.Properties["RequestType"] = isHtmxRequest ? "htmx-partial" : "full-page";
if (!string.IsNullOrEmpty(htmxTarget))
requestTelemetry.Properties["HX-Target"] = htmxTarget;
if (!string.IsNullOrEmpty(htmxTrigger))
requestTelemetry.Properties["HX-Trigger"] = htmxTrigger;
if (!string.IsNullOrEmpty(htmxTriggerName))
requestTelemetry.Properties["HX-Trigger-Name"] = htmxTriggerName;
if (!string.IsNullOrEmpty(handler))
requestTelemetry.Properties["Handler"] = handler;
}
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
// Track custom metrics for htmx requests
if (isHtmxRequest)
{
var metricName = $"HtmxResponse/{handler ?? "default"}";
_telemetryClient.TrackMetric(new MetricTelemetry
{
Name = "HtmxResponseTime",
Sum = stopwatch.ElapsedMilliseconds,
Count = 1,
Properties =
{
["Handler"] = handler ?? "none",
["Target"] = htmxTarget ?? "none",
["StatusCode"] = context.Response.StatusCode.ToString()
}
});
// Track response size for htmx responses
if (context.Response.ContentLength.HasValue)
{
_telemetryClient.TrackMetric(new MetricTelemetry
{
Name = "HtmxResponseSize",
Sum = context.Response.ContentLength.Value,
Count = 1,
Properties =
{
["Handler"] = handler ?? "none"
}
});
}
}
// Log slow requests
if (stopwatch.ElapsedMilliseconds > 1000)
{
_logger.LogWarning(
"Slow request: {Path} took {Duration}ms (htmx: {IsHtmx}, handler: {Handler})",
context.Request.Path,
stopwatch.ElapsedMilliseconds,
isHtmxRequest,
handler);
}
}
}
}
public static class HtmxTelemetryMiddlewareExtensions
{
public static IApplicationBuilder UseHtmxTelemetry(this IApplicationBuilder app)
{
return app.UseMiddleware<HtmxTelemetryMiddleware>();
}
}
Registration in Program.cs:
// After UseRouting, before UseEndpoints
app.UseHtmxTelemetry();
#
23.6.2 Logging Best Practices
Serilog provides structured logging with multiple sinks for different environments.
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.ApplicationInsights
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Expressions
Complete Serilog Configuration in Program.cs:
using Serilog;
using Serilog.Events;
// Configure Serilog early
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog from appsettings
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithProperty("Application", "ChinookDashboard"));
// ... rest of configuration
var app = builder.Build();
// Add request logging
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("IsHtmxRequest", httpContext.Request.Headers.ContainsKey("HX-Request"));
var handler = httpContext.Request.Query["handler"].FirstOrDefault();
if (!string.IsNullOrEmpty(handler))
diagnosticContext.Set("Handler", handler);
};
// Don't log health checks
options.GetLevel = (httpContext, elapsed, ex) =>
{
if (httpContext.Request.Path.StartsWithSegments("/health"))
return LogEventLevel.Verbose;
return elapsed > 1000 ? LogEventLevel.Warning : LogEventLevel.Information;
};
});
// ... rest of app configuration
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
appsettings.Production.json Serilog Section:
{
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.ApplicationInsights",
"Serilog.Sinks.File"
],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "ApplicationInsights",
"Args": {
"connectionString": "SET_IN_AZURE",
"telemetryConverter": "Serilog.Sinks.ApplicationInsights.TelemetryConverters.TraceTelemetryConverter, Serilog.Sinks.ApplicationInsights"
}
},
{
"Name": "File",
"Args": {
"path": "/home/LogFiles/Application/chinook-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
],
"Filter": [
{
"Name": "ByExcluding",
"Args": {
"expression": "RequestPath like '/health%'"
}
},
{
"Name": "ByExcluding",
"Args": {
"expression": "@m like '%password%' or @m like '%token%' or @m like '%secret%'"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithEnvironmentName"
]
}
}
#
23.6.3 Performance Monitoring
#
Key Metrics for htmx Applications
Track these metrics in Application Insights:
- Response Time: Average and P95 for htmx vs full page requests
- Throughput: Requests per second by handler
- Error Rate: Failed requests percentage
- Response Size: Average partial response size
#
Custom Application Insights Queries
Query htmx-specific performance data in Log Analytics:
// Average response time by handler for htmx requests
requests
| where customDimensions.IsHtmxRequest == "True"
| summarize
AvgDuration = avg(duration),
P95Duration = percentile(duration, 95),
RequestCount = count()
by Handler = tostring(customDimensions.Handler)
| order by RequestCount desc
// Slow htmx requests (> 500ms)
requests
| where customDimensions.IsHtmxRequest == "True"
| where duration > 500
| project
timestamp,
name,
duration,
Handler = customDimensions.Handler,
Target = customDimensions["HX-Target"],
resultCode
| order by duration desc
// htmx vs full page request comparison
requests
| summarize
AvgDuration = avg(duration),
Count = count()
by RequestType = tostring(customDimensions.RequestType)
// Error rate by handler
requests
| where customDimensions.IsHtmxRequest == "True"
| summarize
TotalRequests = count(),
FailedRequests = countif(toint(resultCode) >= 400)
by Handler = tostring(customDimensions.Handler)
| extend ErrorRate = (FailedRequests * 100.0) / TotalRequests
| order by ErrorRate desc
#
Azure Monitor Alerts
Create alerts for critical conditions:
# Alert for high error rate
az monitor metrics alert create \
--name "HighErrorRate" \
--resource-group chinook-rg \
--scopes "/subscriptions/{sub}/resourceGroups/chinook-rg/providers/Microsoft.Web/sites/chinook-dashboard" \
--condition "avg requests/failed > 10" \
--window-size 5m \
--evaluation-frequency 1m \
--action-group chinook-alerts \
--description "High error rate detected"
# Alert for slow response times
az monitor metrics alert create \
--name "SlowResponses" \
--resource-group chinook-rg \
--scopes "/subscriptions/{sub}/resourceGroups/chinook-rg/providers/Microsoft.Web/sites/chinook-dashboard" \
--condition "avg HttpResponseTime > 2000" \
--window-size 5m \
--evaluation-frequency 1m \
--action-group chinook-alerts \
--description "Response times exceeding 2 seconds"
#
23.6.4 Debugging Production Issues
#
Azure Kudu Console
Access the Kudu console at https://{app-name}.scm.azurewebsites.net:
- Debug Console: Browse files, run commands
- Process Explorer: View running processes
- Log Stream: Real-time log viewing
- Environment: Check environment variables
#
Log Streaming via CLI
# Stream logs in real-time
az webapp log tail \
--name chinook-dashboard \
--resource-group chinook-rg
# Download logs
az webapp log download \
--name chinook-dashboard \
--resource-group chinook-rg \
--log-file logs.zip
#
Common htmx Deployment Issues
Anti-forgery Token Failures:
System.InvalidOperationException: The antiforgery token could not be decrypted.
Solution: Configure data protection key storage (see Section 23.8.2).
Missing Partial Views (Linux case sensitivity):
InvalidOperationException: The view 'Artists/_ArtistRow' was not found.
Solution: Ensure file names match exactly, including case. _artistRow.cshtml won't be found if code references _ArtistRow.
CORS Issues with CDN:
Access to fetch at 'https://cdn...' from origin 'https://app...' has been blocked by CORS policy
Solution: Configure CORS headers on CDN or serve htmx responses from the origin.
#
Troubleshooting Checklist
#
23.7 Scaling and Performance
#
23.7.1 Horizontal Scaling
Scale out to multiple instances for high traffic.
#
Auto-Scale Configuration
# Enable auto-scale
az monitor autoscale create \
--resource-group chinook-rg \
--name chinook-autoscale \
--resource "/subscriptions/{sub}/resourceGroups/chinook-rg/providers/Microsoft.Web/serverFarms/chinook-plan" \
--min-count 1 \
--max-count 5 \
--count 1
# Add scale-out rule (CPU > 70%)
az monitor autoscale rule create \
--resource-group chinook-rg \
--autoscale-name chinook-autoscale \
--condition "CpuPercentage > 70 avg 5m" \
--scale out 1
# Add scale-in rule (CPU < 30%)
az monitor autoscale rule create \
--resource-group chinook-rg \
--autoscale-name chinook-autoscale \
--condition "CpuPercentage < 30 avg 10m" \
--scale in 1
# Add memory-based rule
az monitor autoscale rule create \
--resource-group chinook-rg \
--autoscale-name chinook-autoscale \
--condition "MemoryPercentage > 80 avg 5m" \
--scale out 1
#
Session Affinity for htmx
htmx applications are typically stateless, making session affinity unnecessary. Disable it for better load distribution:
# Disable session affinity (ARR Affinity)
az webapp config set \
--name chinook-dashboard \
--resource-group chinook-rg \
--generic-configurations '{"clientAffinityEnabled": false}'
Enable session affinity only if your application stores session state in memory (not recommended for production).
#
23.7.2 Vertical Scaling
# Scale up to P2v3
az appservice plan update \
--name chinook-plan \
--resource-group chinook-rg \
--sku P2V3
#
Load Testing with k6
loadtest.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 }, // Ramp up
{ duration: '1m', target: 20 }, // Stay at 20 users
{ duration: '30s', target: 50 }, // Ramp to 50
{ duration: '1m', target: 50 }, // Stay at 50
{ duration: '30s', target: 0 }, // Ramp down
],
};
const BASE_URL = 'https://chinook-dashboard.azurewebsites.net';
export default function () {
// Full page load
let res = http.get(`${BASE_URL}/Artists`);
check(res, { 'page loaded': (r) => r.status === 200 });
sleep(1);
// htmx search request
res = http.get(`${BASE_URL}/Artists?handler=List&search=AC`, {
headers: {
'HX-Request': 'true',
'HX-Target': 'artist-list',
},
});
check(res, { 'htmx search ok': (r) => r.status === 200 });
sleep(0.5);
// htmx edit form
res = http.get(`${BASE_URL}/Artists?handler=Edit&id=1`, {
headers: {
'HX-Request': 'true',
'HX-Target': 'artist-row-1',
},
});
check(res, { 'htmx edit ok': (r) => r.status === 200 });
sleep(1);
}
Run with: k6 run loadtest.js
#
23.7.3 Output Caching
ASP.NET Core 7+ includes built-in output caching.
Output Caching Configuration:
// In Program.cs
builder.Services.AddOutputCache(options =>
{
// Default policy: no caching
options.AddBasePolicy(builder => builder.NoCache());
// Cache static lookups for 1 hour
options.AddPolicy("StaticLookup", builder => builder
.Expire(TimeSpan.FromHours(1))
.SetVaryByHeader("HX-Request")
.Tag("lookup"));
// Cache search results for 5 minutes, vary by search term
options.AddPolicy("SearchResults", builder => builder
.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("search", "page")
.SetVaryByHeader("HX-Request")
.Tag("search"));
// No cache for user-specific content
options.AddPolicy("NoCache", builder => builder.NoCache());
});
// For multi-instance, use Redis
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "chinook:";
});
// In middleware pipeline
app.UseOutputCache();
Applying Cache Policies to Handlers:
// In page model
[OutputCache(PolicyName = "StaticLookup")]
public async Task<IActionResult> OnGetGenreListAsync()
{
var genres = await _genreService.GetAllAsync();
return Partial("_GenreList", genres);
}
[OutputCache(PolicyName = "SearchResults")]
public async Task<IActionResult> OnGetListAsync(string? search, int page = 1)
{
var artists = await _artistService.SearchAsync(search, page);
return Partial("_ArtistList", artists);
}
[OutputCache(PolicyName = "NoCache")]
public async Task<IActionResult> OnGetEditAsync(int id)
{
var artist = await _artistService.GetByIdAsync(id);
return Partial("_ArtistEditForm", artist);
}
Redis Cache Configuration:
# Create Azure Cache for Redis
az redis create \
--name chinook-cache \
--resource-group chinook-rg \
--location eastus \
--sku Basic \
--vm-size c0
# Get connection string
az redis list-keys \
--name chinook-cache \
--resource-group chinook-rg
#
23.7.4 Database Performance
#
Azure SQL Recommendations
- Enable Query Store for performance analysis
- Use Automatic Tuning for index recommendations
- Configure Read Scale-Out for read-heavy workloads
# Enable Query Store
az sql db update \
--name ChinookDb \
--resource-group chinook-rg \
--server chinook-sql \
--set requestedServiceObjectiveName="S1"
# Enable automatic tuning
az sql db update \
--name ChinookDb \
--resource-group chinook-rg \
--server chinook-sql \
--set automaticTuning="Auto"
#
Connection Resiliency
Already configured in Program.cs with EnableRetryOnFailure. For htmx endpoints with many small queries, connection pooling is critical:
// In connection string
"Max Pool Size=100;Min Pool Size=10;Connection Timeout=30;"
#
23.8 Security Hardening
#
23.8.1 Security Headers
htmx requires specific CSP configuration to allow inline event handlers and dynamic content loading.
Middleware/SecurityHeadersMiddleware.cs
namespace ChinookDashboard.Middleware;
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
private readonly SecurityHeadersOptions _options;
public SecurityHeadersMiddleware(RequestDelegate next, SecurityHeadersOptions options)
{
_next = next;
_options = options;
}
public async Task InvokeAsync(HttpContext context)
{
// Content Security Policy - htmx compatible
var csp = BuildContentSecurityPolicy();
context.Response.Headers["Content-Security-Policy"] = csp;
// Prevent MIME type sniffing
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
// Clickjacking protection
context.Response.Headers["X-Frame-Options"] = _options.AllowFraming ? "SAMEORIGIN" : "DENY";
// Referrer policy
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
// Permissions policy (formerly Feature-Policy)
context.Response.Headers["Permissions-Policy"] =
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
// Remove server header
context.Response.Headers.Remove("Server");
context.Response.Headers.Remove("X-Powered-By");
await _next(context);
}
private string BuildContentSecurityPolicy()
{
var directives = new List<string>
{
// Default: only same origin
"default-src 'self'",
// Scripts: self + htmx + hyperscript + unsafe-inline for htmx attributes
// In production, consider using nonces instead of unsafe-inline
$"script-src 'self' {(_options.CdnDomain != null ? _options.CdnDomain : "")} 'unsafe-inline' 'unsafe-eval'",
// Styles: self + inline styles for htmx indicators
$"style-src 'self' {(_options.CdnDomain != null ? _options.CdnDomain : "")} 'unsafe-inline'",
// Images: self + data URIs + CDN
$"img-src 'self' data: {(_options.CdnDomain != null ? _options.CdnDomain : "")}",
// Fonts: self + CDN
$"font-src 'self' {(_options.CdnDomain != null ? _options.CdnDomain : "")}",
// Connect: same origin for htmx requests
"connect-src 'self'",
// Forms: same origin
"form-action 'self'",
// Frame ancestors: prevent embedding
"frame-ancestors 'none'",
// Base URI: same origin
"base-uri 'self'",
// Object/embed: none
"object-src 'none'"
};
return string.Join("; ", directives);
}
}
public class SecurityHeadersOptions
{
public bool AllowFraming { get; set; } = false;
public string? CdnDomain { get; set; }
public bool UseStrictCsp { get; set; } = false;
}
public static class SecurityHeadersMiddlewareExtensions
{
public static IServiceCollection AddSecurityHeaders(
this IServiceCollection services,
Action<SecurityHeadersOptions>? configure = null)
{
var options = new SecurityHeadersOptions();
configure?.Invoke(options);
services.AddSingleton(options);
return services;
}
public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app)
{
var options = app.ApplicationServices.GetService<SecurityHeadersOptions>()
?? new SecurityHeadersOptions();
return app.UseMiddleware<SecurityHeadersMiddleware>(options);
}
}
Configuration in Program.cs:
// Services
builder.Services.AddSecurityHeaders(options =>
{
options.CdnDomain = "https://chinook-assets.azureedge.net";
options.AllowFraming = false;
});
// Middleware (early in pipeline)
if (!app.Environment.IsDevelopment())
{
app.UseSecurityHeaders();
app.UseHsts();
}
#
23.8.2 Anti-Forgery in Production
In multi-instance deployments, data protection keys must be shared across instances.
Data Protection Configuration:
// In Program.cs
using Azure.Identity;
using Azure.Storage.Blobs;
using Microsoft.AspNetCore.DataProtection;
// Configure data protection for production
if (builder.Environment.IsProduction())
{
var blobServiceClient = new BlobServiceClient(
new Uri("https://chinookstorage.blob.core.windows.net"),
new DefaultAzureCredential());
var containerClient = blobServiceClient.GetBlobContainerClient("data-protection-keys");
builder.Services.AddDataProtection()
.SetApplicationName("ChinookDashboard")
.PersistKeysToAzureBlobStorage(containerClient, "keys.xml")
.ProtectKeysWithAzureKeyVault(
new Uri("https://chinook-vault.vault.azure.net/keys/DataProtectionKey"),
new DefaultAzureCredential());
}
else
{
builder.Services.AddDataProtection()
.SetApplicationName("ChinookDashboard");
}
Azure Resource Setup:
# Create storage account for data protection keys
az storage account create \
--name chinookstorage \
--resource-group chinook-rg \
--location eastus \
--sku Standard_LRS
# Create container
az storage container create \
--name data-protection-keys \
--account-name chinookstorage \
--auth-mode login
# Create Key Vault key for encryption
az keyvault key create \
--vault-name chinook-vault \
--name DataProtectionKey \
--protection software
# Grant Web App access to storage
WEBAPP_IDENTITY=$(az webapp identity show --name chinook-dashboard --resource-group chinook-rg --query principalId -o tsv)
az role assignment create \
--assignee $WEBAPP_IDENTITY \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/{sub}/resourceGroups/chinook-rg/providers/Microsoft.Storage/storageAccounts/chinookstorage"
# Grant access to Key Vault key
az keyvault set-policy \
--name chinook-vault \
--object-id $WEBAPP_IDENTITY \
--key-permissions get unwrapKey wrapKey
#
23.8.3 Rate Limiting
Protect htmx endpoints from abuse with ASP.NET Core rate limiting.
// In Program.cs
using System.Threading.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
// Global rate limit
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 10
});
});
// Stricter limit for search (htmx debounced, but protect against abuse)
options.AddPolicy("search", context =>
{
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 5
});
});
// Very strict for write operations
options.AddPolicy("write", context =>
{
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 2
});
});
// Custom response for htmx requests
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
if (context.HttpContext.Request.Headers.ContainsKey("HX-Request"))
{
context.HttpContext.Response.ContentType = "text/html";
await context.HttpContext.Response.WriteAsync(
"<div class=\"text-red-600\">Too many requests. Please slow down.</div>",
cancellationToken);
}
else
{
await context.HttpContext.Response.WriteAsync(
"Rate limit exceeded. Please try again later.",
cancellationToken);
}
};
});
// In middleware pipeline (after routing)
app.UseRateLimiter();
Applying Rate Limits to Handlers:
// In page model
[EnableRateLimiting("search")]
public async Task<IActionResult> OnGetListAsync(string? search)
{
// ...
}
[EnableRateLimiting("write")]
public async Task<IActionResult> OnPostCreateAsync()
{
// ...
}
#
23.9 Cost Optimization
#
23.9.1 Right-Sizing Resources
Monitor actual usage before choosing tiers:
# View App Service metrics
az monitor metrics list \
--resource "/subscriptions/{sub}/resourceGroups/chinook-rg/providers/Microsoft.Web/sites/chinook-dashboard" \
--metric "CpuPercentage,MemoryPercentage" \
--interval PT1H \
--start-time 2024-01-01T00:00:00Z
# Get Azure Advisor recommendations
az advisor recommendation list \
--resource-group chinook-rg \
--category cost
#
23.9.2 Cost-Saving Strategies
Auto-Shutdown for Dev/Test:
# Create automation account and schedule
# Or use built-in App Service feature for dev/test slots
# Stop staging slot during off-hours
az webapp stop \
--name chinook-dashboard \
--resource-group chinook-rg \
--slot staging
Estimated Monthly Costs (East US):
#
23.10 Summary
Deploying htmx applications to Azure requires attention to configuration, security, monitoring, and performance. This chapter covered the complete deployment lifecycle.
#
Deployment Checklist
#
Key Azure Services
#
CI/CD Pipeline Flow
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
│ │ Push │ → │ Build │ → │ Test │ → │ Staging │ │
│ │ to main │ │ │ │ │ │ Deploy │ │
│ └─────────┘ └─────────┘ └─────────┘ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ Health │ │
│ │ Check │ │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌─────────┐ │
│ │ Approval │ → │ Swap │ │
│ │ (manual) │ │ Slots │ │
│ └──────────┘ └─────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
#
htmx-Specific Deployment Tips
Compression: Enable Brotli compression for htmx partial responses. Even small fragments benefit from compression.
Caching: Use output caching selectively. Cache static lookups and navigation, but never user-specific content or form submissions.
Error Handling: Return HTML partials for htmx errors using HX-Retarget. Full error pages break the htmx UX.
Health Checks: Include an htmx-compatible health endpoint that returns HTML for dashboard displays.
Telemetry: Track htmx requests separately in Application Insights. Add HX-Target and handler as custom properties.
Security Headers: Configure CSP to allow htmx's inline event handlers (
unsafe-inlineor nonces).Rate Limiting: Apply stricter limits to search endpoints even with client-side debouncing.
#
Companion Code Files
chap23/
├── ChinookDashboard/
│ ├── appsettings.json
│ ├── appsettings.Production.json
│ ├── Program.cs
│ ├── Middleware/
│ │ ├── HtmxTelemetryMiddleware.cs
│ │ ├── HtmxResponseCachingMiddleware.cs
│ │ ├── ProductionExceptionMiddleware.cs
│ │ └── SecurityHeadersMiddleware.cs
│ ├── Health/
│ │ ├── DatabaseHealthCheck.cs
│ │ └── HealthCheckExtensions.cs
│ └── Pages/
│ └── Shared/
│ ├── _Error404.cshtml
│ └── _Error500.cshtml
├── .github/
│ └── workflows/
│ ├── build-test.yml
│ ├── deploy.yml
│ └── azure-deploy.yml
├── infrastructure/
│ ├── azure-setup.sh
│ ├── setup-cdn.sh
│ ├── setup-domain.sh
│ └── create-service-principal.sh
├── tests/
│ └── loadtest.js
├── bundleconfig.json
├── libman.json
└── README.md
With these configurations in place, your htmx application is ready for production traffic on Azure. The automated pipeline ensures consistent deployments, while monitoring and alerting provide visibility into application health. As traffic grows, the scaling configurations handle increased load automatically.