Ansible Azure Automation: Production-Grade Infrastructure as Code That Won't Fail at 3 AM
Ansible Azure Automation deep dive: production patterns, failure modes, and real incident recovery for DevOps engineers.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- ✓An Azure subscription. Azure CLI installed (
az loginworking). A service principal with Contributor permissions.
Ansible automates Azure by using the azure_rm_* modules (e.g., azure_rm_virtualmachine) which call the Azure REST API via the Azure SDK for Python. You authenticate with a service principal or managed identity, define desired state in YAML, and Ansible handles the API calls to converge your Azure resources to that state.
Imagine you're a property manager with 100 rental units. Instead of walking each unit to check if the lights are on, you have a checklist (playbook) that says 'every unit must have lights on by 8 PM.' Your assistant (Ansible) reads the checklist, calls each unit's smart switch (Azure API), and flips the switch if it's off. If a unit doesn't exist, the assistant builds it. If the switch is broken, the assistant reports exactly which unit and why.
I've seen Ansible Azure automation fail spectacularly at 2 AM when a misconfigured service principal expired and a critical VM scale set couldn't be updated. The runbook errored silently, the team assumed success, and the production deployment went sideways. The root cause? A 30-second TTL on the cached token that nobody accounted for.
This isn't a tutorial on 'how to create a VM with Ansible.' You can find that on any blog. This is about making Ansible Azure automation production-hardened: handling API rate limits, managing state across hundreds of resources, and debugging failures when the Azure API returns a 429 or a cryptic AuthorizationFailed.
By the end of this, you'll be able to design Ansible playbooks that survive token expiry, throttle gracefully, and integrate with Azure DevOps pipelines without causing a stampede on the Azure Resource Manager API. You'll also know the exact commands to run when your automation breaks at 3 AM.
Why Ansible for Azure? The Problem with ARM Templates and CLI Scripts
Before Ansible, Azure automation meant ARM templates (JSON nightmares) or brittle bash scripts with az commands. ARM templates are declarative but lack idempotency checks—you can't easily say 'ensure this VM exists with these settings' without blowing away the old one. CLI scripts are imperative and fail halfway, leaving resources in inconsistent states.
Ansible solves this with its azure_rm_* modules that are idempotent: they compare the current state of the Azure resource against the desired state in your playbook and only make API calls if something changed. This is critical for production where you can't afford to recreate a database server just because you ran a playbook twice.
But idempotency isn't free. The modules must query Azure's API to get current state before deciding what to change. That means every playbook run incurs API calls just for 'check mode'—and Azure Resource Manager has rate limits (12,000 reads per hour per subscription). You'll hit those limits if you manage hundreds of resources naively.
throttle or serial to control concurrency. Better yet, use azure_rm_resource with a single API call to fetch all VMs in a resource group.Authentication: Service Principals, Managed Identities, and Token Expiry
The most common production failure with Ansible Azure automation is authentication. You set up a service principal, store the secret in Ansible Vault, and it works for a week. Then the playbook fails with AuthenticationFailedError: The client secret expired.
Service principal secrets have a max lifetime of 2 years (or less if your org rotates them). If you're not rotating secrets, your automation will break. The fix is to use managed identities for Azure resources when running Ansible from an Azure VM or Azure DevOps agent. Managed identities auto-rotate credentials and eliminate secret management.
For local development or external runners, use Azure AD application with certificate-based authentication—certificates can be rotated via automation. Never hardcode secrets. Use Ansible Vault with a vault password stored in Azure Key Vault, retrieved at runtime via the azure_rm_keyvault_secret module.
Token expiry is another silent killer. The Azure SDK caches tokens for 30 seconds by default. If your playbook takes longer than that between tasks, the token may expire mid-playbook. The symptom is intermittent AuthorizationFailed errors. Mitigation: set AZURE_TOKEN_CACHE_EXPIRATION_SECONDS to 300 or use azure_rm_resource with api_version to force a fresh token.
AZURE_TOKEN_CACHE_EXPIRATION_SECONDS=300 before running Ansible. This prevents token expiry during long playbooks. I've debugged a 3-hour outage caused by this default.Idempotency and State Management: The Hidden API Cost
Ansible's azure_rm_* modules are idempotent, but they achieve this by making GET requests to Azure before every PUT/POST/DELETE. For a playbook that manages 100 resources, that's 100 GET calls just to check state—plus the actual mutation calls. At scale, this burns through your API rate limit.
Worse, some modules like azure_rm_virtualmachine don't support partial updates. If you change only the VM size, the module may still call CreateOrUpdate with the full VM configuration, which can trigger a reboot. That's not idempotent in practice—it's destructive.
Solution: Use azure_rm_resource module for fine-grained control. It lets you call specific Azure REST API endpoints with PUT, PATCH, or GET. For example, to update only the VM size without rebooting, use PATCH on the Microsoft.Compute/virtualMachines resource. This reduces API calls and avoids unintended side effects.
Another pattern: Use azure_rm_resource_facts to gather current state of all resources in a single API call (list operation), then compare locally. This cuts the number of GET calls from N to 1.
azure_rm_virtualmachine with vm_size changed will call CreateOrUpdate (PUT) which can reboot the VM. Always use azure_rm_resource with PATCH for partial updates.Error Handling and Retry Logic for Azure API Failures
Azure APIs are reliable, but they do throttle (HTTP 429) and occasionally return 5xx errors. Ansible's default behavior is to fail immediately—which is fine for a lab, but in production you need retries with exponential backoff.
Ansible's until loop combined with retries and delay gives you control. But there's a gotcha: the until condition checks the result of the task, and if the task fails, the result variable may not be defined. You need to use failed_when: false to prevent the task from failing before the loop.
Also, not all errors are retryable. A 400 Bad Request won't become a 200 on retry. So check the HTTP status code before retrying. Use response.status_code from the module's return value.
Here's a pattern I use in production: retry on 429 and 5xx, fail fast on 4xx (except 429). Also, log the retry count to a file for monitoring.
delay doesn't support jitter natively, so add a random component: delay: "{{ (10 (2 * (ansible_loop.index - 1))) | random(seed=play_uuid) }}".Scaling Ansible for Large Azure Deployments: Throttling and Parallelism
Running Ansible against 500 Azure resources sequentially takes hours. But running them all in parallel will hit API rate limits and saturate your network. The sweet spot is to control concurrency with Ansible's serial and throttle directives.
serial controls how many hosts are processed at once in a play. For Azure, you're often running against localhost (since Ansible runs locally and calls Azure APIs), so serial doesn't help directly. Instead, use throttle on individual tasks to limit how many concurrent API calls a task makes.
But throttle is per-task, not global. If you have 10 tasks, each can have its own throttle, but the total API calls can still spike. Better approach: use azure_rm_resource with batch operations where possible. For example, to create 100 VMs, use a single ARM template deployment via azure_rm_deployment instead of 100 individual azure_rm_virtualmachine calls.
Another pattern: use include_tasks with loop and throttle to process resources in batches. This gives you fine-grained control over the API call rate.
azure_rm_deployment with an ARM template. It's a single API call that creates multiple resources, respects idempotency, and is faster than looping over individual modules.Integrating with Azure DevOps Pipelines: Secrets and Dynamic Inventory
Running Ansible from Azure DevOps is common, but it introduces two problems: secret management and dynamic inventory. For secrets, use Azure Key Vault task to fetch secrets and pass them as environment variables. Never store service principal secrets in pipeline variables—they're not encrypted at rest in classic pipelines.
For dynamic inventory, use the azure_rm.py inventory script (now deprecated) or the azure_rm plugin. The plugin queries Azure for VMs and returns them as hosts. But beware: the plugin fetches all VMs in a subscription by default, which can be slow and expose unintended hosts. Use include_vm_resource_groups to limit scope.
Also, the plugin doesn't handle Azure VMSS instances well—it returns the scale set name, not individual instances. For VMSS, use azure_rm_virtualmachinescaleset module directly.
Here's a production pipeline snippet that uses managed identity (Azure DevOps agent running on an Azure VM) and dynamic inventory.
- task: AzureKeyVault@1 with connectedServiceNameARM and keyVaultName.When NOT to Use Ansible for Azure Automation
Ansible is great for configuration management and simple provisioning, but it's not the right tool for every job. Avoid Ansible for:
- Stateful infrastructure with complex dependencies: If you need to manage a multi-tier app with rolling updates, health checks, and canary deployments, use Terraform or Bicep for provisioning and Kubernetes for orchestration.
- High-frequency updates: Ansible's pull-based model (SSH or local execution) doesn't scale for real-time resource changes. Use Azure Functions or Event Grid for event-driven automation.
- Teams that don't know YAML: If your team is more comfortable with Python or TypeScript, consider Pulumi or Azure SDK directly.
- Environments with strict compliance logging: Ansible's default logging is minimal. You'd need to implement custom logging and audit trails, which is easier with Azure Policy or Azure Blueprints.
My rule of thumb: Use Ansible when you need to 'bootstrap and configure'—install software, set configs, ensure a VM is in a known state. Use Terraform when you need to 'provision and version'—create resource groups, networks, and managed services. Use both together (Terraform for infra, Ansible for config) for a robust pipeline.
azure_rm_sqldatabase doesn't exist. Use Terraform for data plane resources. Ansible is for compute and config.The 4GB Container That Kept Dying
ResourceDeploymentFailure after 10 minutes. The container would start, then crash. No logs.azure_rm_containerinstance module doesn't surface container exit codes by default.memory_in_gb: 2.0 and cpu: 2 in the playbook. Also added az container logs to the playbook's post_tasks for debugging.- Always over-provision resources by 25% for container workloads, and always capture container logs in your automation.
Error: HTTP 429: Too Many Requeststhrottle: 5 to that task. 3. Add retry logic with exponential backoff (see section above). 4. Consider batching resources with ARM templates.AuthorizationFailed error despite valid credentialsaz role assignment list --assignee <sp-id> --output table. 2. Check if the secret expired: az ad sp credential list --id <sp-id>. 3. If using managed identity, ensure the Azure VM has managed identity enabled and the correct role assigned.state: present/absent. 2. Verify that all parameters match desired state exactly (e.g., vm_size case-sensitive). 3. Use --check mode to see what would change. 4. If using azure_rm_resource, ensure method is correct (PATCH vs PUT).az role assignment list --assignee <sp-id> --output tableaz account show --query 'id' -o tsvaz role assignment create --assignee <sp-id> --role Contributor --scope /subscriptions/<sub-id>/resourceGroups/<rg>| File | Command / Code | Purpose |
|---|---|---|
| deploy_vm.yml | - name: Ensure production VM exists | Why Ansible for Azure? The Problem with ARM Templates and CL |
| auth_with_managed_identity.yml | - name: Deploy resources using managed identity | Authentication |
| idempotent_vm_update.yml | - name: Update VM size idempotently | Idempotency and State Management |
| retry_azure_api.yml | - name: Create resource group with retry | Error Handling and Retry Logic for Azure API Failures |
| batch_vm_creation.yml | - name: Deploy multiple VMs via ARM template | Scaling Ansible for Large Azure Deployments |
| azure-pipelines.yml | trigger: | Integrating with Azure DevOps Pipelines |
Key takeaways
Common mistakes to avoid
3 patternsNot scoping playbooks with Azure tags
tags filters in Azure playbooks. An untagged playbook targeting 'all VMs' can accidentally affect production resources.Hardcoding Azure subscription IDs
Missing Python dependency installation
pip install -r ~/.ansible/collections/ansible_collections/azure/azcollection/requirements.txt.Interview Questions on This Topic
How does Ansible handle idempotency for Azure resources, and what are the performance implications at scale?
azure_rm_* modules call the Azure GET API to fetch current state before deciding to make changes. This means each task incurs at least one GET call. At scale (hundreds of resources), this can exhaust the Azure API rate limit (12,000 reads/hour). Mitigation: use azure_rm_resource_facts to batch state retrieval, or use azure_rm_deployment with ARM templates to reduce API calls.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Ansible. Mark it forged?
5 min read · try the examples if you haven't