Building a Modern Development Platform: Deploying Platform Documentation with Azure Storage and Front Door π
- Introduction
- Table of Contents
- Why Static Site Hosting on Azure Storage? πΎ
- Architecture Overview ποΈ
- What is TechDocs? π
- Setting Up Terraform Cloud βοΈ
- Azure Infrastructure with Terraform
- Azure Infrastructure with Terraform ποΈ
- Setting Up TechDocs π
- Setting Up Azure DevOps π
- Azure DevOps Pipeline
- Deployment Workflow π
- Monitoring and Analytics
- Testing the Deployment β
- Best Practices π―
- Troubleshooting π§
- Cost Optimization π°
- Cleaning Up Resources π§Ή
- Summary
Introduction
A modern development platform needs excellent documentation. But documentation infrastructure shouldnβt be expensive or complex to maintain. In this post, weβll deploy a static documentation site to Azure Storage with Azure Front Door for global distribution - all managed through Terraform Cloud and automated with Azure DevOps pipelines.
This approach gives us:
- Low Cost: Azure Storage static websites cost pennies per month
- Global Performance: Front Door CDN distributes content worldwide
- Automated Deployment: Azure DevOps pipelines rebuild and deploy on every commit
- Infrastructure as Code: Terraform Cloud manages all Azure resources
- Developer-Friendly: TechDocs generates beautiful documentation from Markdown
The code for this post is available in the blog-platform-aspire repository.
Table of Contents
- Why Static Site Hosting on Azure Storage?
- Architecture Overview
- What is TechDocs?
- Setting Up Terraform Cloud
- Azure Infrastructure with Terraform
- Setting Up TechDocs
- Setting Up Azure DevOps
- Azure DevOps Pipeline
- Deployment Workflow
- Monitoring and Analytics
- Testing the Deployment
- Best Practices
- Troubleshooting
- Cost Optimization
- Cleaning Up Resources
Why Static Site Hosting on Azure Storage? πΎ
Traditional web hosting requires web servers, compute resources, and ongoing maintenance. Static site hosting on Azure Storage eliminates all of that:
Cost Comparison: π°
- App Service Basic (B1): ~$13/month
- Azure Storage static website: ~$0.50/month
- Savings: 96% reduction
What You Get: β¨
- β 99.9% availability SLA
- β Automatic scaling
- β HTTPS support (with Front Door)
- β Custom domain support
- β CDN integration
- β No server management
Perfect For: π―
- π Documentation sites
- π± Marketing pages
- βοΈ Single-page applications
- π° Static blog hosting
- π¨ Design system showcases
Detailed Cost Comparison with Azure Static Web Apps π
When choosing between static site hosting options, hereβs a comprehensive breakdown:
Azure Storage + Front Door (This Tutorial) π’
| Resource | Cost |
|---|---|
| ποΈ Storage Account (LRS) | $0.50 |
| π Front Door (Standard) | $35.00 |
| π Data Transfer | $0.01-0.10 |
| Monthly Total | ~$36/month |
Benefits: β
- β Global CDN distribution
- β Managed SSL certificates
- β Custom domains with HTTPS
- β Advanced caching rules
- β Security headers via rules
- β High availability SLA
Use when: π€
- You need global CDN performance
- Custom domain is essential
- Professional/production documentation
- Medium to high traffic sites
Azure Static Web Apps (Free Alternative) π
| Resource | Cost |
|---|---|
| π Static Web App (Free tier) | $0.00 |
| β‘ Azure Functions (if needed) | $5-15 |
| π Data Transfer | Included |
| Monthly Total | ~$0-15/month |
Benefits:
- β No baseline cost
- β Git integration (auto-deploy)
- β Serverless backend (optional)
- β Built-in staging environments
- β Custom domains supported
- β Managed SSL certificates
Limitations:
- β No CDN (slower global distribution)
- β No advanced caching rules
- β Limited to free tier features initially
- β Cold start latency for functions
Use when:
- Cost is the primary concern
- Documentation accessed mainly from single region
- Lower traffic volumes
- Team/internal documentation
- Quick MVP deployment
Decision Matrix
| Factor | Storage + Front Door | Static Web Apps |
|---|---|---|
| Startup Cost | ~$36/month | ~$0/month |
| Global Performance | Excellent | Good (no CDN) |
| Setup Complexity | Medium | Low |
| Custom Domain | Yes (with HTTPS) | Yes (with HTTPS) |
| Deployment | Pipeline-driven | Git-integrated |
| Scaling | Automatic | Automatic |
| Best For | Production docs | Quick prototypes |
Cost-Benefit Analysis
Choose Storage + Front Door if:
- π° Budget is available for production setup
- π Global audience needs fast access
- π Professional appearance important
- π High traffic expected (>10GB/month)
- π― Custom domain essential for branding
Choose Static Web Apps if:
- π° Minimizing costs critical
- π Primarily regional/single-region audience
- β‘ Quick time-to-market needed
- π Lower traffic (<5GB/month)
- π’ Internal/team documentation
The Hidden Win: Consolidating Multiple Sites on One Front Door π
Hereβs where Storage + Front Door becomes incredibly cost-effective: if youβre already hosting other sites or APIs behind a Front Door instance, adding documentation is nearly free.
Scenario: You Already Have Front Door π‘
| Resource | Single Site | 2-3 Sites | 4+ Sites |
|---|---|---|---|
| π Front Door Standard | $35.00 | $35.00 | $35.00 |
| ποΈ Storage Account #1 | $0.50 | $0.50 | $0.50 |
| ποΈ Storage Account #2 | β | $0.50 | $0.50 |
| ποΈ Storage Account #3 | β | $0.50 | $0.50 |
| π Data Transfer | $0.10 | $0.30 | $0.50 |
| Monthly Total | $35.60 | $36.80 | $37.50 |
| Cost Per Site | $35.60 | $12.27 | $9.38 |
Real-World Advantage: π°
Once Front Door is running, each additional storage account costs only ~$0.50-1.00/month! This is dramatically cheaper than:
- β Static Web Apps at $9-36/month per site
- β Additional App Service instances
- β Separate CDN configurations
Multi-Site Architecture Example: ποΈ
Front Door (Standard) - $35/month π
βββ π API Documentation (Storage)
βββ π° Engineering Blog (Storage)
βββ π¨ Design System (Storage)
βββ π Product Dashboards (Storage)
βββ π§ Developer Portal (Storage)
Total Cost: ~$36-37/month for 5 sites Cost Per Site: ~$7.40/month π΅
When to Use This Pattern β
β Perfect for organizations where:
- π Multiple documentation/content sites needed
- π Front Door already deployed for API distribution
- π° Company paying $35/month anyway for CDN
- π Want unified SSL, caching, security policies
- π Need audit trail for content delivery
β Examples: π
- Main API docs + Engineering blog + Design system
- Internal wiki + Public knowledge base + Marketing site
- Multiple product documentation sites
- Team/department resource portals
β οΈ Trade-offs: β οΈ
- π€ Shared Front Door instance (coordinate rules with other teams)
- π All sites use same security headers/caching policies
- π― Single point of configuration (can affect multiple sites)
- π May need governance around content management
Architecture Overview ποΈ
Our documentation deployment pipeline looks like this:
βββββββββββββββββββ
β Git Repository β
β (Markdown) β
ββββββββββ¬βββββββββ
β
β Push triggers pipeline
β
ββββββββββΌβββββββββββββ
β Azure DevOps β
β Pipeline β
β - Build TechDocs β
β - Run Tests β
β - Deploy to Azure β
ββββββββββ¬βββββββββββββ
β
β Upload static files
β
ββββββββββΌβββββββββββββ
β Azure Storage β
β Static Website β
β - HTML/CSS/JS β
β - Images/Assets β
ββββββββββ¬βββββββββββββ
β
β Content distribution
β
ββββββββββΌβββββββββββββ
β Azure Front Door β
β - Global CDN β
β - SSL/TLS β
β - Custom Domain β
β - Caching β
βββββββββββββββββββββββ
What is TechDocs? π
TechDocs is the documentation system built into Backstage. It converts Markdown files into beautiful, searchable documentation sites with:
- Markdown-Based: βοΈ Write docs in plain Markdown with your code
- Built-in Search: π Full-text search across all documentation
- Component-Aware: π Documentation tied to specific services/components
- Beautiful UI: π¨ Modern, responsive design out of the box
- Version Control: π¦ Docs live with code, versioned together
Even if youβre not using Backstage, TechDocs can generate standalone documentation sites.
Setting Up Terraform Cloud βοΈ
Before we create Azure resources, we need to set up Terraform Cloud to manage our infrastructure state and execute our deployments.
Creating the Workspace in Terraform Cloud π§
- Log into Terraform Cloud at https://app.terraform.io
- Create a new workspace:
- Click βNew workspaceβ
- Choose βCLI-driven workflowβ
- Name it
documentation - Add a description: βPlatform documentation infrastructureβ
- Configure workspace settings:
- Go to Settings β General
- Set Execution Mode: βRemoteβ
- Set Terraform Version: β1.5.0β or later
- Enable βAuto applyβ if you want automatic deployments (optional)
Configure Workspace Variables π
In the workspace, go to Variables and add the following:
Environment Variables (for Azure authentication): π
ARM_CLIENT_ID: Your Azure service principal client IDARM_CLIENT_SECRET: Your Azure service principal secret (mark as sensitive)ARM_SUBSCRIPTION_ID: Your Azure subscription IDARM_TENANT_ID: Your Azure tenant ID
Terraform Variables:
location:westus2project_name:platformdocscustom_domain: Leave empty or set to your custom domain (e.g.,docs.yourdomain.com)
Getting Azure Service Principal Credentials π
If you donβt have a service principal yet, create one:
# Login to Azure
az login
# Create service principal
az ad sp create-for-rbac \
--name "sp-terraform-platform-docs" \
--role Contributor \
--scopes /subscriptions/{subscription-id}
# Output will show:
# {
# "appId": "...", # Use this for ARM_CLIENT_ID
# "password": "...", # Use this for ARM_CLIENT_SECRET
# "tenant": "..." # Use this for ARM_TENANT_ID
# }
# Get your subscription ID
az account show --query id -o tsv # Use this for ARM_SUBSCRIPTION_ID
Azure Infrastructure with Terraform
Letβs build the infrastructure step by step. Weβll create all the Terraform files in the documentation/infra folder.
Azure Infrastructure with Terraform ποΈ
Terraform Provider Configuration π₯οΈ
Create documentation/infra/provider.tf to configure the AzureRM provider and Terraform Cloud backend:
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.50"
}
}
cloud {
organization = "your-org-name" # Replace with your Terraform Cloud organization
workspaces {
name = "documentation"
}
}
}
provider "azurerm" {
features {
resource_group {
prevent_deletion_if_contains_resources = true
}
key_vault {
purge_soft_delete_on_destroy = false
recover_soft_deleted_key_vaults = true
}
}
}
Resource Group and Common Resources π¦
Create documentation/infra/main.tf for the resource group and common tags:
locals {
environment = "test" # Hardcoded for this deployment
common_tags = {
environment = local.environment
purpose = "platform-documentation"
managed_by = "terraform"
project = var.project_name
}
}
resource "azurerm_resource_group" "docs" {
name = "rg-${var.project_name}-${local.environment}"
location = var.location
tags = local.common_tags
}
Storage Account for Static Website ποΈ
Create documentation/infra/storage.tf for the storage account configured for static website hosting:
Important Note on Storage Account Naming: β οΈ Azure Storage account names must be globally unique across all of Azure, 3-24 characters long, and contain only lowercase letters and numbers. In our case, weβre using st${var.project_name} which creates stplatformdocs - if this name is already taken globally, youβll need to modify the project_name variable to make it unique.
resource "azurerm_storage_account" "docs" {
name = "st${var.project_name}"
resource_group_name = azurerm_resource_group.docs.name
location = azurerm_resource_group.docs.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
tags = local.common_tags
}
resource "azurerm_storage_account_static_website" "docs" {
storage_account_id = azurerm_storage_account.docs.id
index_document = "index.html"
error_404_document = "404.html"
}
# Output the primary endpoint
output "static_website_url" {
value = azurerm_storage_account.docs.primary_web_endpoint
description = "The URL of the static website"
}
output "storage_account_name" {
value = azurerm_storage_account.docs.name
description = "The name of the storage account"
}
Azure Front Door for Global Distribution π
Create documentation/infra/frontdoor.tf for Azure Front Door CDN and custom domain support:
resource "azurerm_cdn_frontdoor_profile" "docs" {
name = "fd-${var.project_name}"
resource_group_name = azurerm_resource_group.docs.name
sku_name = "Standard_AzureFrontDoor"
tags = local.common_tags
}
resource "azurerm_cdn_frontdoor_endpoint" "docs" {
name = "ep-${var.project_name}"
cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.docs.id
tags = local.common_tags
}
resource "azurerm_cdn_frontdoor_origin_group" "docs" {
name = "og-${var.project_name}"
cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.docs.id
load_balancing {
sample_size = 4
successful_samples_required = 3
}
health_probe {
path = "/"
request_type = "HEAD"
protocol = "Https"
interval_in_seconds = 100
}
}
resource "azurerm_cdn_frontdoor_origin" "docs" {
name = "origin-${var.project_name}"
cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.docs.id
enabled = true
host_name = replace(replace(azurerm_storage_account.docs.primary_web_endpoint, "https://", ""), "/", "")
http_port = 80
https_port = 443
origin_host_header = replace(replace(azurerm_storage_account.docs.primary_web_endpoint, "https://", ""), "/", "")
priority = 1
weight = 1000
certificate_name_check_enabled = true
}
resource "azurerm_cdn_frontdoor_route" "docs" {
name = "route-${var.project_name}"
cdn_frontdoor_endpoint_id = azurerm_cdn_frontdoor_endpoint.docs.id
cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.docs.id
cdn_frontdoor_origin_ids = [azurerm_cdn_frontdoor_origin.docs.id]
cdn_frontdoor_custom_domain_ids = var.custom_domain != "" ? [azurerm_cdn_frontdoor_custom_domain.docs[0].id] : []
supported_protocols = ["Http", "Https"]
patterns_to_match = ["/*"]
forwarding_protocol = "HttpsOnly"
link_to_default_domain = true
https_redirect_enabled = true
depends_on = [
azurerm_cdn_frontdoor_origin.docs,
azurerm_cdn_frontdoor_custom_domain.docs
]
}
output "frontdoor_endpoint" {
value = azurerm_cdn_frontdoor_endpoint.docs.host_name
description = "The Front Door endpoint hostname"
}
resource "azurerm_cdn_frontdoor_custom_domain" "docs" {
count = var.custom_domain != "" ? 1 : 0
name = "custom-domain-${var.project_name}"
cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.docs.id
host_name = var.custom_domain
tls {
certificate_type = "ManagedCertificate"
}
}
Variables π
Create documentation/infra/variables.tf for the input variables:
variable "project_name" {
description = "Project name for resource naming"
type = string
default = "platformdocs"
validation {
condition = length(var.project_name) <= 15 && can(regex("^[a-z0-9]+$", var.project_name))
error_message = "Project name must be 15 characters or less and contain only lowercase letters and numbers."
}
}
variable "location" {
description = "Azure region for resources"
type = string
default = "westus2"
}
variable "custom_domain" {
description = "Custom domain for documentation site (optional)"
type = string
default = ""
}
Setting Up TechDocs π
TechDocs requires a specific project structure. Hereβs a minimal setup:
Documentation Structure ποΈ
repo-root/
βββ documentation/ # Main documentation folder
β βββ mkdocs.yml # TechDocs configuration
β βββ docs/ # Markdown documentation files
β β βββ index.md # Home page
β β βββ getting-started/
β β β βββ index.md
β β β βββ quickstart.md
β β βββ architecture/
β β β βββ index.md
β β β βββ decisions.md
β β βββ api/
β β βββ index.md
β β βββ reference.md
β βββ site/ # Generated output (gitignored)
β βββ infra/ # Terraform infrastructure
β βββ provider.tf
β βββ main.tf
β βββ variables.tf
β βββ storage.tf
β βββ frontdoor.tf
β βββ monitoring.tf
MkDocs Configuration π
Create documentation/mkdocs.yml:
site_name: Platform Documentation
site_description: Modern Development Platform Documentation
site_author: Your Team
repo_url: https://github.com/yourorg/platform-docs
edit_uri: edit/main/documentation/docs/
# Markdown files are in the docs/ subfolder
docs_dir: docs
theme:
name: material
palette:
primary: indigo
accent: indigo
features:
- navigation.tabs
- navigation.sections
- navigation.expand
- search.suggest
- search.highlight
nav:
- Home: index.md
- Getting Started:
- Overview: getting-started/index.md
- Quickstart: getting-started/quickstart.md
- Architecture:
- Overview: architecture/index.md
- Decisions: architecture/decisions.md
- API Reference:
- Overview: api/index.md
- Reference: api/reference.md
markdown_extensions:
- admonition
- codehilite
- pymdownx.superfences
- pymdownx.tabbed
- toc:
permalink: true
plugins:
- search
- techdocs-core
Building TechDocs Locally
Install TechDocs CLI and test locally:
# Install TechDocs CLI
npm install -g @techdocs/cli
# Or use npx
npx @techdocs/cli
# Navigate to the documentation folder (where mkdocs.yml is located)
cd documentation
# Generate documentation (requires Docker)
techdocs-cli generate --source-dir . --output-dir ./site
# Preview locally
techdocs-cli serve --source-dir .
Open http://localhost:3000 to preview your documentation.
Troubleshooting TechDocs Generation:
If you get a Docker error like Docker container returned a non-zero exit code (1):
- Ensure Docker is running: TechDocs requires Docker to be running
docker ps - Use the no-docker option: Generate without Docker for local testing
techdocs-cli generate --source-dir . --output-dir ./site --no-docker - Install dependencies locally: If using
--no-docker, install Python dependencies# Install Python 3 if not already installed (macOS) brew install python3 # Or on Ubuntu/Debian sudo apt-get install python3 python3-pip # Install mkdocs-techdocs-core (user-level to avoid permission issues) pip3 install --user mkdocs-techdocs-core # Or use a virtual environment (recommended) python3 -m venv venv source venv/bin/activate pip install mkdocs-techdocs-core - Check mkdocs.yml: Ensure your configuration is valid
mkdocs build --strict -
Common mkdocs errors:
Missing dependencies: If you get module import errors
pip install --user mkdocs-material pymdown-extensionsInvalid navigation: Check your
navsection inmkdocs.ymlfor typos or missing files# Test build to see specific error (run from documentation folder where mkdocs.yml is) cd documentation mkdocs build --verbosePlugin errors: Ensure
techdocs-coreplugin is properly installedpip show mkdocs-techdocs-core
Setting Up Azure DevOps π
Before we create the pipeline, we need to set up authentication and permissions in Azure DevOps.
Terraform Cloud Token
To create a Terraform Cloud team token:
- Log into Terraform Cloud at https://app.terraform.io
- Go to your organization settings
- Click βTeamsβ β Select your team (or create one)
- Click βTeam API tokenβ
- Click βCreate a team tokenβ
- Copy the token and save it securely
- Use this token for the
TERRAFORM_CLOUD_TOKENvariable in Azure DevOps
In Azure DevOps, create a pipeline variable for the Terraform Cloud token:
- Go to Pipelines β Select your pipeline β Edit
- Click βVariablesβ in the top right
- Click β+ Addβ
- Name:
TERRAFORM_CLOUD_TOKEN - Value: Your Terraform Cloud team token
- Keep this value secret: β Check this box
- Click βOKβ
Service Connection
Create an Azure Resource Manager service connection in Azure DevOps:
- Go to Project Settings β Service connections
- Create new service connection β Azure Resource Manager
- Authentication method: Select βWorkload Identity federation (automatic)β
- Scope level: Choose your subscription
- Resource group: Leave blank (do not select a specific resource group)
- Service connection name:
documentation - Click βSaveβ
Important: Donβt scope the service connection to a specific resource group. While this gives broader subscription-level access for control plane operations (creating/managing resources), weβll use Azure RBAC to grant specific data plane permissions in the next step.
Azure DevOps will automatically create an App Registration in your Azure AD and configure the workload identity federation for secure, keyless authentication.
Grant Storage Permissions
The service connection created in Azure DevOps has subscription-level permissions for control plane operations (managing Azure resources like creating storage accounts, Front Door, etc.), but it needs additional data plane permissions to upload and manage blobs within the storage account.
Control Plane vs Data Plane:
- Control Plane: Managing Azure resources themselves (create, update, delete resources). The service connection has this via its Contributor role at the subscription level.
- Data Plane: Accessing and managing data within resources (uploading blobs, reading files, etc.). This requires separate RBAC assignments.
The service principal needs the Storage Blob Data Owner role to upload documentation files to the storage account:
# Get the service principal Object ID from the service connection
# (You can find this in Azure DevOps: Service Connection β Manage Service Principal β Object ID)
# Assign Storage Blob Data Owner role (data plane access)
az role assignment create \
--assignee <service-principal-object-id> \
--role "Storage Blob Data Owner" \
--scope /subscriptions/<subscription-id>/resourceGroups/rg-platformdocs-test/providers/Microsoft.Storage/storageAccounts/stplatformdocstest
Alternatively, assign the role through the Azure Portal:
- Navigate to your storage account in the Azure Portal
- Click βAccess Control (IAM)β in the left menu
- Click β+ Addβ β βAdd role assignmentβ
- Select βStorage Blob Data Ownerβ role
- Click βNextβ
- Select βUser, group, or service principalβ
- Click β+ Select membersβ
- Search for your service connection name (
documentation) - Select it and click βSelectβ
- Click βReview + assignβ
Why Storage Blob Data Owner?
- This role provides full access to blob data (read, write, delete)
- Required for the
az storage blob upload-batchandaz storage blob delete-batchcommands in the pipeline - Scoped to just the storage account, following least-privilege principles
Azure DevOps Pipeline
Now letβs automate the deployment with Azure DevOps.
Pipeline Configuration
Create azure-pipelines.yml in your repository:
trigger:
branches:
include:
- main
paths:
include:
- documentation/docs/**
- documentation/mkdocs.yml
- documentation/infra/**
- .azdo/documentation.yml
pool:
vmImage: 'ubuntu-latest'
variables:
- name: storageAccount
value: 'stplatformdocstest'
- name: containerName
value: '\$web'
- name: TF_CLOUD_TOKEN
value: '$(TERRAFORM_CLOUD_TOKEN)'
stages:
- stage: Build
displayName: 'Build Documentation'
dependsOn: []
jobs:
- job: BuildDocs
displayName: 'Build TechDocs'
steps:
- task: NodeTool@0
inputs:
versionSpec: '24.x'
displayName: 'Install Node.js'
- script: |
npm install -g @techdocs/cli
displayName: 'Install TechDocs CLI'
- script: |
cd documentation
techdocs-cli generate --source-dir . --output-dir ./site
displayName: 'Generate Documentation'
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: 'documentation/site'
ArtifactName: 'documentation'
publishLocation: 'Container'
displayName: 'Publish Documentation Artifact'
- stage: DeployInfrastructure
displayName: 'Deploy Infrastructure'
dependsOn: []
jobs:
- job: TerraformApply
displayName: 'Apply Terraform'
steps:
- task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0
inputs:
terraformVersion: 'latest'
displayName: 'Install Terraform'
- script: |
cat > ~/.terraformrc << EOF
credentials "app.terraform.io" {
token = "$TERRAFORM_CLOUD_TOKEN"
}
EOF
displayName: 'Configure Terraform Cloud Credentials'
env:
TERRAFORM_CLOUD_TOKEN: $(TERRAFORM_CLOUD_TOKEN)
- script: |
cd documentation/infra
terraform init
displayName: 'Terraform Init'
- script: |
cd documentation/infra
terraform apply -auto-approve
displayName: 'Terraform Apply'
- stage: Deploy
displayName: 'Deploy to Azure'
dependsOn:
- Build
- DeployInfrastructure
condition: succeeded()
jobs:
- deployment: DeployDocs
displayName: 'Deploy to Storage'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- task: DownloadBuildArtifacts@0
inputs:
buildType: 'current'
downloadType: 'single'
artifactName: 'documentation'
downloadPath: '$(System.ArtifactsDirectory)'
displayName: 'Download Documentation'
- task: AzureCLI@2
inputs:
azureSubscription: 'documentation'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
# Remove old files
az storage blob delete-batch --account-name $(storageAccount) --source $(containerName) --auth-mode login
# Upload new files
az storage blob upload-batch --account-name $(storageAccount) --destination $(containerName) --source $(System.ArtifactsDirectory)/documentation --overwrite true --auth-mode login
displayName: 'Deploy to Storage Account'
- task: AzureCLI@2
inputs:
azureSubscription: 'documentation'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
# Purge Front Door cache
az afd endpoint purge --resource-group rg-platformdocs-test --profile-name fd-platformdocs-test --endpoint-name ep-platformdocs-test --content-paths "/*"
displayName: 'Purge Front Door Cache'
Key Pipeline Features:
- Trigger Paths: Pipeline runs when documentation files or the pipeline itself changes
- Storage Account Variable: Set to
stplatformdocstest- matches the storage account name created by Terraform - Container Name: Escaped as
\$webto prevent variable expansion - Terraform Cloud Token: Uses environment variable in the script for proper credential handling
- Parallel Stages: Build and DeployInfrastructure run in parallel for faster execution
- TerraformInstaller Task: Uses the full task ID
ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0
Deployment Workflow π
Hereβs the complete workflow for deploying documentation to the test environment:
1. Configure Azure DevOps Pipeline Variables
The pipeline variables are already set correctly:
storageAccount:stplatformdocstest(matches Terraform output)containerName:$webTERRAFORM_CLOUD_TOKEN: Added as a secret variable
2. Grant Permissions to Service Principal
# Get your service principal object ID from Azure DevOps service connection
# Then assign the role using the actual storage account name
az role assignment create \
--assignee <service-principal-object-id> \
--role "Storage Blob Data Owner" \
--scope /subscriptions/<subscription-id>/resourceGroups/rg-platformdocs-test/providers/Microsoft.Storage/storageAccounts/stplatformdocstest
3. Run the Pipeline
Once configured, the pipeline will:
- Build documentation from Markdown
- Deploy/update infrastructure via Terraform Cloud (automatically creates resources on first run)
- Upload to storage account
- Purge Front Door cache
The infrastructure stage is idempotent - it wonβt recreate resources if they already exist. On the first pipeline run, Terraform will create all the Azure resources. On subsequent runs, it will only update resources if changes are detected.
Note: You donβt need to run Terraform locally for deployment. The pipeline handles all infrastructure provisioning and updates through Terraform Cloud. The only time youβd run Terraform locally is to destroy resources when cleaning up (see the βCleaning Up Resourcesβ section below).
Custom Domain Configuration (Optional)
To use a custom domain like docs.yourdomain.com:
Set the Custom Domain Variable
The custom domain support is already built into the frontdoor.tf configuration. To enable it, simply set the custom_domain Terraform variable in your Terraform Cloud workspace:
- Go to your Terraform Cloud workspace β Variables
- Add or update the
custom_domainvariable with your domain (e.g.,docs.yourdomain.com) - Run the pipeline - Terraform will automatically create the custom domain resource and configure Front Door
DNS Configuration
After setting the custom domain variable and applying it, you need to validate domain ownership:
Step 1: Add the custom domain CNAME
Add a CNAME record pointing to your Front Door endpoint:
docs.yourdomain.com CNAME ep-{your-project-name}-{random-hash}.azurefd.net
Step 2: Validate domain ownership
- Go to the Azure Portal
- Navigate to your Front Door resource
- Click βDomainsβ in the left menu
- Find your custom domain and click on it
- Look at the βValidation stateβ section
- Youβll see a TXT record that needs to be added to your DNS:
- Record type: TXT
- Record name:
_dnsauth.docs.yourdomain.com - Record value: A unique validation token (e.g.,
_jm6ytg2a7tnl53777awtblog2f8tylh)
- Add this TXT record to your DNS provider
- Wait for DNS propagation (can take up to 15-30 minutes)
- Azure will automatically validate the domain once the TXT record is detected
- Once validated, Front Door will provision a managed SSL certificate
Note: The TXT record is only needed for validation. You can remove it after the domain is validated, but keeping it doesnβt cause any issues.
Front Door will automatically provision and manage the SSL certificate for your custom domain once validation is complete.
Monitoring and Analytics
Add monitoring to track documentation usage:
Application Insights Integration
Create documentation/infra/monitoring.tf for Application Insights:
resource "azurerm_application_insights" "docs" {
name = "ai-${var.project_name}-${local.environment}"
resource_group_name = azurerm_resource_group.docs.name
location = azurerm_resource_group.docs.location
application_type = "web"
tags = local.common_tags
}
output "instrumentation_key" {
value = azurerm_application_insights.docs.instrumentation_key
sensitive = true
description = "Application Insights instrumentation key"
}
output "connection_string" {
value = azurerm_application_insights.docs.connection_string
sensitive = true
description = "Application Insights connection string"
}
Add the Application Insights snippet to your documentation template.
Testing the Deployment β
After your pipeline runs, test the deployment:
# Test the storage endpoint directly
# Replace with your actual storage account name from your project_name variable
curl https://st{your-project-name}.z5.web.core.windows.net/
# Test through Front Door
# Replace with your actual Front Door endpoint from Azure Portal or Terraform Cloud outputs
curl https://ep-{your-project-name}-{random-hash}.azurefd.net/
# Get the exact URLs from Terraform Cloud:
# 1. Log into https://app.terraform.io
# 2. Navigate to your organization β documentation workspace
# 3. Go to "States" β View latest state
# 4. Look for outputs: static_website_url and frontdoor_endpoint
#
# Or view outputs in Azure Portal:
# - Storage endpoint: Storage Account β Static website β Primary endpoint
# - Front Door endpoint: Front Door β Endpoint hostname
# Test custom domain (if configured)
curl https://docs.yourdomain.com/
Best Practices π―
1. Compression
Enable compression in Front Door for better performance:
resource "azurerm_cdn_frontdoor_rule" "compression" {
name = "compression"
cdn_frontdoor_route_id = azurerm_cdn_frontdoor_route.docs.id
order = 1
behavior_on_match = "Continue"
actions {
response_header_action {
header_action = "Append"
header_name = "Content-Encoding"
value = "gzip"
}
}
conditions {
request_header_condition {
header_name = "Accept-Encoding"
operator = "Contains"
match_values = ["gzip"]
}
}
}
2. Security Headers
Add security headers to your documentation:
resource "azurerm_cdn_frontdoor_rule" "security_headers" {
name = "security-headers"
cdn_frontdoor_route_id = azurerm_cdn_frontdoor_route.docs.id
order = 2
behavior_on_match = "Continue"
actions {
response_header_action {
header_action = "Append"
header_name = "X-Content-Type-Options"
value = "nosniff"
}
response_header_action {
header_action = "Append"
header_name = "X-Frame-Options"
value = "DENY"
}
response_header_action {
header_action = "Append"
header_name = "Strict-Transport-Security"
value = "max-age=31536000; includeSubDomains"
}
}
}
Troubleshooting π§
Documentation Not Updating
If your documentation doesnβt update after deployment:
- Check the pipeline: Verify the build and deploy stages succeeded
- Purge the cache: Front Door caches content aggressively
az afd endpoint purge \ --resource-group rg-platformdocs-test \ --profile-name fd-platformdocs-test \ --endpoint-name ep-platformdocs-test \ --content-paths "/*" - Verify upload: Check the storage account to ensure files were uploaded
az storage blob list \ --account-name stplatformdocstest \ --container-name '$web' \ --output table
404 Errors
If you get 404 errors:
- Check index document: Ensure
index.htmlexists in the root - Verify static website: Confirm static website hosting is enabled
- Check routing: Verify Front Door route configuration
Custom Domain Not Working
If your custom domain doesnβt work:
- Verify DNS: Check CNAME record propagation
nslookup docs.yourdomain.com - Check certificate: Ensure the managed certificate is provisioned (can take 15-30 minutes)
- Review association: Verify the custom domain association in Front Door
Cost Optimization π°
Our documentation setup is already cost-effective, but you can optimize further:
1. Use Storage Account LRS
Weβre already using Locally Redundant Storage (LRS) which is the cheapest option.
2. Monitor Front Door Usage
Front Door Standard costs ~$35/month base rate plus:
- $0.01 per GB of data transfer
- $0.20 per million requests
For a documentation site with moderate traffic:
- 10 GB transfer/month: $0.10
- 100K requests/month: $0.02
3. Consider Front Door Classic
For very low traffic sites, Front Door Classic might be cheaper:
- No base rate
- Pay only for usage
- Less features, but sufficient for documentation
Monthly Cost Breakdown
Test/Development Setup:
- Storage Account: $0.50
- Front Door Standard: $35.00
- Data transfer (minimal): $0.01
- Total: ~$36/month
Production Setup:
- Storage Account: $1.00 (more traffic)
- Front Door Standard: $35.00
- Data transfer (10 GB): $0.10
- Total: ~$36/month
Development-Only Setup (no Front Door):
- Storage Account only: $0.50
- Use direct storage endpoint
- Total: ~$0.50/month
Note: For this tutorial, weβre deploying to a test environment with the full Front Door setup to demonstrate the complete solution. In practice, you might skip Front Door for development environments and use only the storage account endpoint to save costs.
Cleaning Up Resources π§Ή
To delete all resources and avoid charges, youβll need to run Terraform locally. This is the only time you need to run Terraform outside of the pipeline:
# Navigate to infrastructure folder
cd documentation/infra
# Login to Terraform Cloud
terraform login
# Initialize Terraform (connects to Terraform Cloud workspace)
terraform init
# Destroy all resources
terraform destroy
# Confirm the destruction
# This will remove:
# - Front Door profile and endpoints
# - Storage account and all content
# - Resource group
All infrastructure deployment is handled by the Azure DevOps pipeline, but cleanup requires manual intervention to prevent accidental deletion.
Summary
Weβve built a complete documentation deployment pipeline that:
β
Hosts static documentation on Azure Storage for minimal cost
β
Distributes globally with Azure Front Door CDN
β
Generates beautiful docs with TechDocs
β
Automates deployment with Azure DevOps pipelines
β
Manages infrastructure with Terraform Cloud
β
Supports custom domains with managed SSL certificates
β
Deploys to test environment (~$36/month) with same infrastructure as production
Key Takeaways:
- Azure Storage account names must be globally unique - choose a unique project name
- The storage account name is created from
st${var.project_name}- in this examplestplatformdocsfrom project_name βplatformdocsβ - Infrastructure and deployment are fully automated through Terraform Cloud and Azure DevOps
- Same pattern works for dev, test, and production - just change the environment in main.tf
- Front Door adds $35/month but provides global CDN, SSL, and custom domains
This infrastructure gives us enterprise-grade documentation hosting at a fraction of the cost of traditional web hosting, with the reliability and performance of Azureβs global infrastructure.
The pattern weβve built here can be used for any static site - documentation, marketing pages, SPAs, or blogs. The combination of Azure Storage, Front Door, and automated CI/CD creates a robust, scalable, and cost-effective hosting solution.
More
Recent Posts
- » Building a Modern Development Platform: Kiota for Multi-Language API Clients π§
- » Building a Modern Development Platform: Deploying Platform Documentation with Azure Storage and Front Door π
- » Building a Modern Development Platform: Terraform & Terraform Cloud for Azure Infrastructure ποΈ
- » Building a Modern Development Platform: TypeSpec for Contract-First API Development π
- » Building a Modern Development Platform: Aspire for Local Development