Simon Vedder

Cloud Engineer · Zürich

AzureVMCredentialRotation

One deployment, then it runs itself

Subscription scope, dry-run by default. The same deployment configures the runbook through eighteen CR_ automation variables, which is also what the Terraform path sets — a test compares the two sets on every push.

Deploy to Azure
Bicep · subscription scope
az deployment sub create \
  --location switzerlandnorth \
  --template-file deploy/main.bicep \
  --parameters keyVaultName=kv-credentials \
               keyVaultResourceGroupName=rg-vault \
               targetResourceGroupNames='["rg-workloads"]'

# Then opt machines in, one tag at a time.
az vm update --ids $VM --set tags.CredentialRotation=enabled

One deployment creates

  • An Automation Account with a system-assigned identity and the runbook published
  • A schedule, and the role assignments it needs: Secrets Officer on the vault, Virtual Machine Contributor on the scopes you name
  • Optionally, and on by default: a Log Analytics workspace, a custom table, and the workbook below: who read which credential, how long it stayed valid, and what is stuck
  • Nothing on your VMs. Scope is opt-in by tag, so the first run over an untagged estate does nothing at all

Feature flags rather than stacked modules: deployObservability for the audit trail (on), enableRotateOnAccess for rotation after use (off), dryRun for whether anything is actually replaced (on, deliberately). Name resource groups or whole subscriptions anywhere in the tenant and the roles are assigned where the machines are.

Under the hood

One logic, two ways to run it

The module alone covers a handful of machines: install it, name a VM, done. Nothing deployed, nothing tagged. The orchestrator is for the fleet: one Automation Account that runs the same module every six hours, and every run does the same four things.

The four parts and how they talk: Key Vault holds the secrets and their expiry dates, the Automation runbook asks every six hours what is near expiry and who read what, sets credentials through the VMAccess extension, and writes records to Log Analytics
  1. Find the machines in scope

    Every VM that carries CredentialRotation=enabled, in the resource groups or subscriptions you named. Opt-in on purpose: a loop that treated "no secret exists for this VM" as "rotate it" would change the local administrator password of every machine it can see on its first run.

  2. Who read a secret since the last run?

    The vault's audit log says. For each secret a human read, the expiry date moves to eight hours from now. Nothing else happens yet; the next step picks it up. That is all of rotation-after-use, no queue and no second code path.

  3. What is near expiry? Stage, apply, promote

    Every secret is named {vm}-{user}-{kind}, for example jump-01-labadmin-pw. Within 14 days of expiry, or missing: the new value is written to jump-01-labadmin-pw-pending first, set on the VM through the VMAccess extension second, and promoted to the real secret with a new expiry 90 days out third. A crash in between leaves a value that is in the vault and about to work, never one that works and is nowhere.

  4. Write a record

    One row per attempt to the custom table: secret versions before and after, the trigger (age, a read, a missing secret, a resumed staging), never credential material. The workbook reads it.

A read at 09:00 is replaced by 18:00. At 09:05 the read is in the audit log, the 12:00 run moves the expiry to 20:00, the 18:00 run finds a secret inside the threshold and replaces it. Worst case is grace period plus schedule interval, 14 hours with the defaults.
The schedule is also the retry. Every run reconciles the whole estate rather than processing a work item, so a powered-off VM or a throttled call needs no dead-letter queue. Nothing is remembered between runs beyond what is already in Key Vault. Decision record
The module never learns anything on its own. Machines, reads and destinations all arrive as arguments. It does not search for VMs, does not query who read a credential, and does not decide what secrets are called. The tag is the orchestrator's policy, not the module's. Decision record

← Overview·Prerequisites·Commands