When it comes to Infrastructure as Code (IaC), Terraform is an incredibly powerful tool.
It allows us to define, deploy, and maintain entire environments in a repeatable, auditable, and version-controlled way.
But there’s one topic that’s often misunderstood or completely overlooked: secret management.
Passwords, SSH keys, and connection strings are all essential for our infrastructure, yet they’re also among the easiest things to expose — accidentally or otherwise.
Let’s explore how most setups evolve: from bad, to good, to fully automated.
Contents
Stage 1 – The Bad Way
The easiest and most dangerous approach: hardcoding credentials directly into your Terraform code.
resource "azurerm_windows_virtual_machine" "vm" {
name = "vm-demo"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
admin_username = "azureadmin"
admin_password = "SuperSecret123!"
}Yes, it works — the VM will deploy, and you can log in.
But now your password exists in:
- Your .tf files
- Your Terraform state file
- Your Git history (if committed)
- Possibly your CI/CD logs
Even if you move that password to a password manager later, the damage is already done — the secret is stored in plaintext multiple places.
🚫 Why this is bad: Once a secret lands in version control or the Terraform state, it’s exposed permanently.
Rotation becomes painful, and the risk of leakage skyrockets.
Stage 2 – The Decent Way
A slight improvement is using Terraform’s random_password resource to generate credentials dynamically.
resource "random_password" "admin" {
length = 20
special = true
}
resource "azurerm_windows_virtual_machine" "vm" {
name = "vm-demo"
admin_username = "azureadmin"
admin_password = random_password.admin.result
}This eliminates manually created passwords and ensures strong complexity.
You can even output the password temporarily for Bastion or RDP connections.
But the improvement only goes so far.
⚠️ Still risky: The generated password is still stored unencrypted in the Terraform state file which can and should be stored in a centralized storage account (you can adopt this from a previous post).
But anyone with backend access (like an Azure Storage account or Terraform Cloud workspace) can read it.
It never rotates, either.
You’ve improved reproducibility and security up to certain point — but not usability.
Stage 3 – The Good Way
To improve further, we introduce Azure Key Vault as the dedicated secret store.
Terraform can generate a password and securely store it in Key Vault before using it.
resource "random_password" "admin" {
length = 24
special = true
}
resource "azurerm_key_vault_secret" "vm_password" {
name = "vm-admin-password"
value = random_password.admin.result
key_vault_id = azurerm_key_vault.kv.id
}
resource "azurerm_windows_virtual_machine" "vm" {
name = "vm-demo"
admin_username = "azureadmin"
admin_password = azurerm_key_vault_secret.vm_password.value
}Now the password:
- Is randomly generated,
- Stored securely in Key Vault, and
- Referenced by the VM configuration.
However, even this “good” solution isn’t perfect.
Terraform must still “see” the password to apply it to the resource, meaning it’s still stored in plaintext in the Terraform state file.
And, just like before, the secret never expires.
🔒 Better — but static: Key Vault improves storage, but Terraform doesn’t handle lifecycle management or rotation.
❗️Despite…
…the fact that it still can’t handle automated key rotation the usability for connecting via Bastion got improved by a lot.
There is no need to remember or copy&paste any secrets/ssh keys if it is stored within a Azure Key Vault.

The Core Problem
No matter how you define or reference your secrets, Terraform always stores them in the state file.
This is by design — Terraform must know the value to manage it.
That means:
- Secrets are visible in plaintext within the state file.
- There’s no automatic rotation or expiration.
- Once leaked, the secret remains valid indefinitely.
Terraform is fantastic for provisioning infrastructure — but it’s not meant for managing secret lifecycles.

Stage 4 – The Better Way
A more secure approach is to use ephemeral resources to generate and manage secrets at runtime. With this method, sensitive values such as passwords or SSH keys are never stored in the Terraform state, but instead are directly written to a secure store like Azure Key Vault.
#Example - variables.tf
variable "adminpassword_ephemeral" {
type = string
sensitive = true
ephemeral = true
}
variable "adminpassword_version" {
type = number
}
# ---- tf.vars Example
adminpassword_ephemeral = "SuperSecurePassword"
adminpassword_version = 1
#Example - password generation
ephemeral "random_password" "password" {
length = 16
special = true
override_special = "!#$%&*()-_=+[]{}<>:?"
}
#Example - ssh generation
ephemeral "tls_private_key" "ssh" {
algorithm = "RSA"
rsa_bits = 4096
}These secrets are then pushed to Key Vault using the value_wo argument, which stands for write-only. The values exist only during the apply phase and do not persist in the Terraform state:
resource "azurerm_key_vault_secret" "password" {
depends_on = [ ephemeral.random_password.password ]
name = "adminuser-pw"
key_vault_id = azurerm_key_vault.this.id
#ephemeral values
value_wo = ephemeral.random_password.password.result
value_wo_version = 1
}All secrets are temporary in Terraform but persist securely in Key Vault, reducing the risk of accidental exposure.
Notes and Caveats:
- Not every AzureRM provider resource fully supports ephemeral/write-only variables.
- In some cases, the AzAPI provider is required to pass sensitive data without storing it in state.
- value_wo_version and other lifecycle settings ensure the secret can be rotated without causing unnecessary Terraform diffs.
Benefits:
- Secrets never appear in Terraform state files.
- Secrets exist only during runtime, reducing exposure.
- Provides a clean middle ground between “good” (plain variables) and “automated” (full CI/CD secret injection) approaches.
Tips:
Feel free to read more about this approach on:
https://nedinthecloud.com/2025/07/15/write-only-arguments-in-terraform/
And if you are interested in the approach of using the AzAPI provider for passing sensitive data:
https://blog.kewalaka.nz/post/2025/06/ephemeral_azapi/
Stage 5 – My Automated Way
To overcome Terraform’s limitations, I built an Azure Automation Runbook that handles what Terraform cannot: automatic credential rotation.
This PowerShell runbook uses a Managed Identity to authenticate and then:
- Rotates Windows passwords using the VMAccessAgent extension.
- Rotates Linux passwords and SSH keys using the VMAccessForLinux extension.
- Updates Key Vault secrets with the new credentials.
- Runs automatically on a defined schedule via Azure Automation.
🧹 Clean lifecycle: Each SSH key has a defined lifespan, rotates on schedule, and old keys are safely discarded.
This ensures that when team members leave, access is automatically removed after a the next run — no manual cleanup required.
How it fits together
Terraform --> Deploys: VMs + Key Vault + Automation Account + Runbook
↓
Azure Automation Runbook (Rotate-AzVMSecrets.ps1)
↓
Rotates VM Passwords & SSH Keys
↓
Stores new secrets securely in Key VaultEach execution updates both the VMs and the secrets in Key Vault — without manual intervention or re-deployment.
Requirements:
To make sure that Terraform do not change secrets during the next apply you have to implement the following licecycle blocks:
resource "azurerm_key_vault_secret" "password-win" {
name = "${local.vm_win_name}-${local.admin_username}-pw"
...
expiration_date = timeadd(timestamp(), "168h") #7d
lifecycle {
ignore_changes = [ value,tags,expiration_date ]
}
}resource "azurerm_windows_virtual_machine" "win" {
name = local.vm_win_name
...
lifecycle {
ignore_changes = [ admin_password ]
}
}
resource "azurerm_linux_virtual_machine" "unix" {
name = local.vm_unix_name
...
admin_ssh_key {
username = local.admin_username
public_key = tls_private_key.ssh.public_key_openssh
}
lifecycle {
ignore_changes = [ admin_ssh_key, admin_password ]
}
}View my full terraform test environment down below.
✅ Result:
- Secrets and Keys rotate automatically.
- Terraform never touches or stores them.
- Operations are fully contained within Azure.
Runbook:
🧩 Script: simonvedder/Rotate-AzVMSecrets.ps1
<#
.SYNOPSIS
Automated Azure VM credential rotation runbook for Windows and Linux VMs.
.DESCRIPTION
Rotates admin passwords and SSH keys for Azure VMs based on Key Vault expiration dates.
Runs daily via Azure Automation Schedule with Managed Identity.
.PARAMETER KeyVaultName
Name of the Azure Key Vault for storing credentials.
.PARAMETER RotationThresholdDays
Trigger rotation when expiration date is within this many days (default: 7).
.PARAMETER RetentionPolicyDays
Set new secret expiration to this many days from now (default: 90).
.PARAMETER SubscriptionScope
"All" to process all accessible subscriptions, or comma-separated subscription IDs.
.PARAMETER DryRun
Simulate operations without making changes (default: false).
.NOTES
File Name : Rotate-AzVMSecrets.ps1
Author : Simon Vedder
Date : 01.11.2025
Version: 1.0.0
Required Modules: Az.Accounts, Az.Compute, Az.KeyVault, Az.Resources
Required Roles:
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$KeyVaultName,
[Parameter(Mandatory = $false)]
[int]$RotationThresholdDays = 7,
[Parameter(Mandatory = $false)]
[int]$RetentionPolicyDays = 90,
[Parameter(Mandatory = $false)]
[string]$SubscriptionScope = "All",
[Parameter(Mandatory = $false)]
[bool]$DryRun = $false
)
#Requires -Modules Az.Accounts, Az.Compute, Az.KeyVault, Az.Resources
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
function Write-Log {
param([string]$Message, [string]$Level = 'Info')
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Output "$timestamp [$Level] $Message"
}
function New-StrongPassword {
param([int]$Length = 24)
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}|;:,.<>?"
$bytes = New-Object byte[] $Length
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
$rng.GetBytes($bytes)
$rng.Dispose()
$password = -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })
# Ensure complexity
if ($password -notmatch '[a-z]') { $password = $password.Insert(0, 'a') }
if ($password -notmatch '[A-Z]') { $password = $password.Insert(1, 'A') }
if ($password -notmatch '[0-9]') { $password = $password.Insert(2, '1') }
if ($password -notmatch '[^a-zA-Z0-9]') { $password = $password.Insert(3, '!') }
return $password
}
function New-SSHKeyPair {
param([string]$KeyName)
# RSA Key erstellen
$rsa = [System.Security.Cryptography.RSA]::Create(4096)
# Private Key exportieren (PEM/PKCS#8)
$privateKeyBytes = $rsa.ExportPkcs8PrivateKey()
$privateKeyBase64 = [Convert]::ToBase64String($privateKeyBytes, `
[System.Base64FormattingOptions]::InsertLineBreaks)
$privateKey = "-----BEGIN PRIVATE KEY-----`n$privateKeyBase64`n-----END PRIVATE KEY-----`n"
# Public Key im SSH Format
# Wir müssen den RSA Public Key manuell in SSH Format konvertieren
$rsaParams = $rsa.ExportParameters($false)
# SSH-RSA Format: type + exponent + modulus
$typeBytes = [System.Text.Encoding]::ASCII.GetBytes("ssh-rsa")
$typeLength = [BitConverter]::GetBytes($typeBytes.Length)
[Array]::Reverse($typeLength)
$exponentLength = [BitConverter]::GetBytes($rsaParams.Exponent.Length)
[Array]::Reverse($exponentLength)
$modulusLength = [BitConverter]::GetBytes($rsaParams.Modulus.Length + 1)
[Array]::Reverse($modulusLength)
# SSH Public Key zusammenbauen
$buffer = @()
$buffer += $typeLength
$buffer += $typeBytes
$buffer += $exponentLength
$buffer += $rsaParams.Exponent
$buffer += $modulusLength
$buffer += 0x00 # Padding byte
$buffer += $rsaParams.Modulus
$publicKeyBase64 = [Convert]::ToBase64String($buffer)
$publicKey = "ssh-rsa $publicKeyBase64"
$rsa.Dispose()
return @{ PrivateKey = $privateKey; PublicKey = $publicKey }
}
# ============================================================================
# MAIN SCRIPT
# ============================================================================
$startTime = Get-Date
Write-Log "====== VM Credential Rotation Started ======" -Level Info
Write-Log "KeyVault: $KeyVaultName `
| Threshold: $RotationThresholdDays days `
| Retention: $RetentionPolicyDays days" `
-Level Info
# Authenticate with Managed Identity
try {
Write-Log "Connecting with Managed Identity..." -Level Info
$null = Connect-AzAccount -Identity -ErrorAction Stop
Write-Log "Authentication successful" -Level Success
}
catch {
Write-Log "Authentication failed: $_" -Level Error
exit 1
}
# Get subscriptions
try {
if ($SubscriptionScope -eq "All") {
$subscriptions = Get-AzSubscription
}
else {
$subscriptions = ($SubscriptionScope -split ",") `
| ForEach-Object { Get-AzSubscription -SubscriptionId $_.Trim() }
}
Write-Log "Found $($subscriptions.Count) subscription(s)" -Level Success
}
catch {
Write-Log "Failed to get subscriptions: $_" -Level Error
exit 1
}
# Statistics
$stats = @{
TotalVMs = 0
Rotated = 0
Failed = 0
Skipped = 0
}
# Process each subscription
foreach ($sub in $subscriptions) {
Write-Log "Processing subscription: $($sub.Name)" -Level Info
$null = Set-AzContext -SubscriptionId $sub.Id
$vms = Get-AzVM
$stats.TotalVMs += $vms.Count
Write-Log "Found $($vms.Count) VMs" -Level Info
foreach ($vm in $vms) {
try {
Write-Log "----------------------------------------" -Level Info
Write-Log "VM: $($vm.Name) | OS: $($vm.StorageProfile.OsDisk.OsType)" -Level Info
$adminUsername = $vm.OSProfile.AdminUsername
$osType = $vm.StorageProfile.OsDisk.OsType
$needsRotation = $false
# ========== WINDOWS VM ==========
if ($osType -eq "Windows") {
$secretName = "$($vm.Name)-$adminUsername-pw"
# Check if rotation needed
try {
$secret = Get-AzKeyVaultSecret -VaultName $KeyVaultName `
-Name $secretName -ErrorAction Stop
if ($null -eq $secret.Attributes.Expires) {
Write-Log "No expiration date, needs rotation" -Level Warning
$needsRotation = $true
}
else {
$daysUntilExpiration = ($secret.Attributes.Expires - (Get-Date)).Days
Write-Log "Expires in $daysUntilExpiration days" -Level Info
if ($daysUntilExpiration -le $RotationThresholdDays) {
$needsRotation = $true
}
}
}
catch {
Write-Log "Secret not found, needs creation" -Level Warning
$needsRotation = $true
}
# Rotate if needed
if ($needsRotation) {
Write-Log "Rotating Windows password..." -Level Info
# Check VM power state
$vmStatus = Get-AzVM -ResourceGroupName $vm.ResourceGroupName `
-Name $vm.Name -Status
$powerState = $vmStatus.Statuses `
| Where-Object { $_.Code -like "PowerState/*" } `
| Select-Object -ExpandProperty Code
$isRunning = $powerState -eq "PowerState/running"
if (-not $isRunning) {
Write-Log "VM is not running (State: $powerState), skipping rotation" `
-Level Warning
$stats.Skipped++
continue
}
if ($DryRun) {
Write-Log "[DRY-RUN] Would rotate password" -Level Warning
$stats.Skipped++
continue
}
# Generate new password
$newPassword = New-StrongPassword
$securePassword = ConvertTo-SecureString `
-String $newPassword `
-AsPlainText -Force
# Update VM via extension FIRST (before storing in Key Vault)
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
$plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
try {
# Install extension
$null = Set-AzVMExtension `
-ResourceGroupName $vm.ResourceGroupName `
-VMName $vm.Name `
-Name "VMAccessAgent" `
-Publisher "Microsoft.Compute" `
-ExtensionType "VMAccessAgent" `
-TypeHandlerVersion "2.4" `
-Settings @{ UserName = $adminUsername } `
-ProtectedSettings @{ Password = $plainPassword } `
-ErrorAction Stop `
-Location $vm.Location `
-ForceRerun (New-Guid).Guid
Write-Log "VM password updated successfully" -Level Success
# Store in Key Vault ONLY after successful VM update
$expirationDate = (Get-Date).AddDays($RetentionPolicyDays)
$null = Set-AzKeyVaultSecret `
-VaultName $KeyVaultName `
-Name $secretName `
-SecretValue $securePassword `
-Expires $expirationDate `
-Tag @{
VMName = $vm.Name
AdminName = $adminUsername
Type = "Password"
OSType = "Windows"
LastRotated = (Get-Date -Format "yyyy-MM-dd")
}
Write-Log "Password stored in Key Vault" -Level Success
$stats.Rotated++
}
catch {
Write-Log "Failed to update VM password: $_" -Level Error
$stats.Failed++
}
finally {
# Clear memory
$newPassword = $null
$plainPassword = $null
$securePassword = $null
[System.GC]::Collect()
}
}
else {
Write-Log "No rotation needed" -Level Info
$stats.Skipped++
}
}
# ========== LINUX VM ==========
elseif ($osType -eq "Linux") {
$passwordSecretName = "$($vm.Name)-$adminUsername-pw"
$sshSecretName = "$($vm.Name)-$adminUsername-ssh-priv"
$sshSecretPubName = "$($vm.Name)-$adminUsername-ssh-pub"
$passwordAuthEnabled = -not $vm.OSProfile.LinuxConfiguration.DisablePasswordAuthentication
# Rotate password if enabled
if ($passwordAuthEnabled) {
Write-Log "Password authentication enabled" -Level Info
try {
$secret = Get-AzKeyVaultSecret `
-VaultName $KeyVaultName `
-Name $passwordSecretName `
-ErrorAction Stop
if ($null -eq $secret.Attributes.Expires) {
$needsRotation = $true
}
else {
$daysUntilExpiration = ($secret.Attributes.Expires - (Get-Date)).Days
Write-Log "Password expires in $daysUntilExpiration days" `
-Level Info
$needsRotation = ($daysUntilExpiration -le $RotationThresholdDays)
}
}
catch {
$needsRotation = $true
}
if ($needsRotation) {
Write-Log "Rotating Linux password..." -Level Info
# Check VM power state
$vmStatus = Get-AzVM `
-ResourceGroupName $vm.ResourceGroupName `
-Name $vm.Name `
-Status
$powerState = $vmStatus.Statuses `
| Where-Object { $_.Code -like "PowerState/*" } `
| Select-Object -ExpandProperty Code
$isRunning = $powerState -eq "PowerState/running"
if (-not $isRunning) {
Write-Log "VM is not running (State: $powerState), skipping rotation" `
-Level Warning
$stats.Skipped++
continue
}
if ($DryRun) {
Write-Log "[DRY-RUN] Would rotate password" -Level Warning
}
else {
$newPassword = New-StrongPassword
$securePassword = ConvertTo-SecureString -String $newPassword `
-AsPlainText -Force
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
$plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
try {
$protectedSettings = @{
username = $adminUsername
password = $plainPassword
reset_ssh = $false
}
$null = Set-AzVMExtension `
-ResourceGroupName $vm.ResourceGroupName `
-VMName $vm.Name `
-Name "VMAccessForLinux" `
-Publisher "Microsoft.OSTCExtensions" `
-ExtensionType "VMAccessForLinux" `
-TypeHandlerVersion "1.5" `
-ProtectedSettings $protectedSettings `
-ErrorAction Stop `
-Location $vm.Location `
-ForceRerun (New-Guid).Guid
Write-Log "Linux password updated" -Level Success
# Store in Key Vault AFTER successful VM update
$expirationDate = (Get-Date).AddDays($RetentionPolicyDays)
$null = Set-AzKeyVaultSecret `
-VaultName $KeyVaultName `
-Name $passwordSecretName `
-SecretValue $securePassword `
-Expires $expirationDate `
-Tag @{
VMName = $vm.Name
AdminName = $adminUsername
OSType = "Linux"
Type = "Password"
LastRotated = (Get-Date -Format "yyyy-MM-dd")
}
$stats.Rotated++
}
catch {
Write-Log "Failed to update Linux password: $_" -Level Error
$stats.Failed++
}
finally {
$newPassword = $null
$plainPassword = $null
$securePassword = $null
[System.GC]::Collect()
}
}
}
else {
Write-Log "No rotation needed" -Level Info
$stats.Skipped++
}
}
# Rotate SSH key
Write-Log "Checking SSH key..." -Level Info
$needsSSHRotation = $false
try {
$sshSecret = Get-AzKeyVaultSecret `
-VaultName $KeyVaultName `
-Name $sshSecretName `
-ErrorAction Stop
if ($null -eq $sshSecret.Attributes.Expires) {
$needsSSHRotation = $true
}
else {
$daysUntilExpiration = ($sshSecret.Attributes.Expires - (Get-Date)).Days
Write-Log "SSH key expires in $daysUntilExpiration days" -Level Info
$needsSSHRotation = ($daysUntilExpiration -le $RotationThresholdDays)
}
}
catch {
$needsSSHRotation = $true
}
if ($needsSSHRotation) {
Write-Log "Rotating SSH key..." -Level Info
# Check VM power state
$vmStatus = Get-AzVM `
-ResourceGroupName $vm.ResourceGroupName `
-Name $vm.Name `
-Status
$powerState = $vmStatus.Statuses `
| Where-Object { $_.Code -like "PowerState/*" } `
| Select-Object -ExpandProperty Code
$isRunning = $powerState -eq "PowerState/running"
if (-not $isRunning) {
Write-Log "VM is not running (State: $powerState), skipping SSH rotation" `
-Level Warning
$stats.Skipped++
continue
}
if ($DryRun) {
Write-Log "[DRY-RUN] Would rotate SSH key" -Level Warning
$stats.Skipped++
}
else {
$keyPair = New-SSHKeyPair -KeyName "$($vm.Name)-$adminUsername"
$securePrivateKey = ConvertTo-SecureString `
-String $keyPair.PrivateKey `
-AsPlainText -Force
$securePublicKey = ConvertTo-SecureString `
-String $keyPair.PublicKey `
-AsPlainText -Force
try {
$protectedSettings = @{
username = $adminUsername
ssh_key = $keyPair.PublicKey
reset_ssh = $true
remove_prior_keys = $true
}
$null = Set-AzVMExtension `
-ResourceGroupName $vm.ResourceGroupName `
-VMName $vm.Name `
-Name "VMAccessForLinux" `
-Publisher "Microsoft.OSTCExtensions" `
-ExtensionType "VMAccessForLinux" `
-TypeHandlerVersion "1.5" `
-ProtectedSettings $protectedSettings `
-ErrorAction Stop `
-Location $vm.Location `
-ForceRerun (New-Guid).Guid
Write-Log "SSH key updated" -Level Success
# Store in Key Vault AFTER successful VM update
$expirationDate = (Get-Date).AddDays($RetentionPolicyDays)
$null = Set-AzKeyVaultSecret `
-VaultName $KeyVaultName `
-Name $sshSecretName `
-SecretValue $securePrivateKey `
-Expires $expirationDate `
-Tag @{
VMName = $vm.Name
AdminName = $adminUsername
OSType = "Linux"
Type = "SSHKey"
LastRotated = (Get-Date -Format "yyyy-MM-dd")
}
$null = Set-AzKeyVaultSecret `
-VaultName $KeyVaultName `
-Name $sshSecretPubName `
-SecretValue $securePublicKey `
-Expires $expirationDate `
-Tag @{
VMName = $vm.Name
AdminName = $adminUsername
OSType = "Linux"
Type = "SSHPublicKey"
LastRotated = (Get-Date -Format "yyyy-MM-dd")
}
$stats.Rotated++
}
catch {
Write-Log "Failed to update SSH key: $_" -Level Error
$stats.Failed++
}
finally {
$keyPair = $null
$securePrivateKey = $null
[System.GC]::Collect()
}
}
}
else {
Write-Log "SSH key does not need rotation" -Level Info
$stats.Skipped++
}
}
}
catch {
Write-Log "Error processing VM $($vm.Name): $_" -Level Error
$stats.Failed++
}
}
}
# Summary
$duration = (Get-Date) - $startTime
Write-Log "----------------------------------------" -Level Info
Write-Log "=============== Summary ===============" -Level Info
Write-Log "Duration: $($duration.ToString('mm\:ss'))" -Level Info
Write-Log "Total VMs: $($stats.TotalVMs)" -Level Info
Write-Log "Rotated: $($stats.Rotated)" -Level Success
Write-Log "Skipped: $($stats.Skipped)" -Level Info
Write-Log "Failed: $($stats.Failed)" -Level $(if ($stats.Failed -gt 0) { 'Error' } else { 'Info' })
if ($DryRun) {
Write-Log "DRY-RUN MODE - No changes made" -Level Warning
}
Write-Log "Completed" -Level Success
exit $(if ($stats.Failed -gt 0) { 1 } else { 0 })Seamless Access via Azure Bastion
One of the biggest benefits of this design is that you can connect to your VMs using Azure Bastion without ever handling a password or private key.
No local secrets.
No copy-pasting.
No insecure file sharing.
Bastion pulls credentials directly from Key Vault when needed, providing a fully managed and secure experience.
This is the perfect balance of security and usability — something rarely achieved in secret management.

Conclusion
Terraform is an excellent provisioning tool — but not a secure secret manager.
Its state-handling model inherently exposes sensitive data and doesn’t support key rotation or expiration.
Even with Azure Key Vault integration, the secrets still end up stored unencrypted in the Terraform state.
That’s why the right approach is to separate provisioning from operations.
By combining:
- Terraform for infrastructure deployment,
- Azure Key Vault for secure storage, and
- Azure Automation Runbooks for lifecycle and rotation,
you achieve a cloud-native, fully automated, and highly secure architecture.
From my perspective, this separation of duties is the cleanest and safest way to manage credentials in modern Azure environments.
It ensures that Terraform does what it’s best at — building infrastructure — while Automation handles what Terraform cannot: continuous secret lifecycle management.
💡 Final takeaway:
Use Terraform to deploy.
Use Key Vault to store.
Use Azure Automation to rotate.
That’s the trio that finally makes your secrets safe.
So you’d even be able to use the bad way initially if you combine it with my automated way 😉
Terraform Example:
🧩 main.tf: simonvedder/main.tf
If you want to explore the Terraform configuration that I used to test, check out my example project:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">=4.51.0"
}
random = {
source = "hashicorp/random"
version = ">=3.7.2"
}
}
}
provider "azurerm" {
features {
}
subscription_id = "<subscriptionID>"
}
# Get current subscription
data "azurerm_client_config" "current" {}
###########################################
### Locals ###
###########################################
locals {
resource_group_name = "securetfsecrets"
location = "westeurope"
kv_name = "${local.resource_group_name}-kv"
vm_prefix = "demo-vm"
vm_win_name = "${local.vm_prefix}-win"
vm_unix_name = "${local.vm_prefix}-unix"
admin_username = "superman"
}
###########################################
### Resourcegroup ###
###########################################
# Resource Group
resource "azurerm_resource_group" "this" {
name = local.resource_group_name
location = local.location
}
###########################################
### Network ###
###########################################
# Networking
resource "azurerm_virtual_network" "this" {
name = "vnet"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
}
# Subnet
resource "azurerm_subnet" "this" {
name = "main"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = ["10.0.1.0/24"]
}
###########################################
### KV & Secrets & Keys ###
###########################################
# Key Vault
resource "azurerm_key_vault" "this" {
tenant_id = data.azurerm_client_config.current.tenant_id
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
name = local.kv_name
sku_name = "standard"
rbac_authorization_enabled = true
}
resource "azurerm_role_assignment" "kv_admin" {
scope = azurerm_key_vault.this.id
principal_id = "<userid>"
role_definition_name = "Key Vault Administrator"
}
resource "random_password" "password" {
length = 16
special = true
override_special = "!#$%&*()-_=+[]{}<>:?"
}
resource "azurerm_key_vault_secret" "password-win" {
depends_on = [ random_password.password, azurerm_role_assignment.kv_admin ]
name = "${local.vm_win_name}-${local.admin_username}-pw"
key_vault_id = azurerm_key_vault.this.id
value = random_password.password.result
expiration_date = timeadd(timestamp(), "168h")
tags = {
VMName = local.vm_win_name
AdminName = local.admin_username
OSType = "Windows"
Type = "Password"
LastRotated = formatdate("YYYY-MM-DD", timestamp())
}
lifecycle {
ignore_changes = [ value,tags,expiration_date ]
}
}
resource "azurerm_key_vault_secret" "password-unix" {
depends_on = [ random_password.password, azurerm_role_assignment.kv_admin ]
name = "${local.vm_unix_name}-${local.admin_username}-pw"
key_vault_id = azurerm_key_vault.this.id
value = random_password.password.result
expiration_date = timeadd(timestamp(), "168h")
tags = {
VMName = local.vm_unix_name
AdminName = local.admin_username
OSType = "Linux"
Type = "Password"
LastRotated = formatdate("YYYY-MM-DD", timestamp())
}
lifecycle {
ignore_changes = [ value,tags,expiration_date ]
}
}
resource "tls_private_key" "ssh" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "azurerm_key_vault_secret" "ssh-priv" {
depends_on = [ tls_private_key.ssh, azurerm_role_assignment.kv_admin ]
name = "${local.vm_unix_name}-${local.admin_username}-ssh-priv"
key_vault_id = azurerm_key_vault.this.id
value = tls_private_key.ssh.private_key_pem
expiration_date = timeadd(timestamp(), "168h")
tags = {
VMName = local.vm_unix_name
AdminName = local.admin_username
OSType = "Linux"
Type = "SSHKey"
LastRotated = formatdate("YYYY-MM-DD", timestamp())
}
lifecycle {
ignore_changes = [ value,tags,expiration_date ]
}
}
resource "azurerm_key_vault_secret" "ssh-pub" {
depends_on = [ tls_private_key.ssh, azurerm_role_assignment.kv_admin ]
name = "${local.vm_unix_name}-${local.admin_username}-ssh-pub"
key_vault_id = azurerm_key_vault.this.id
value = tls_private_key.ssh.public_key_openssh
expiration_date = timeadd(timestamp(), "168h")
tags = {
VMName = local.vm_unix_name
AdminName = local.admin_username
OSType = "Linux"
Type = "SSHPublicKey"
LastRotated = formatdate("YYYY-MM-DD", timestamp())
}
lifecycle {
ignore_changes = [ value,tags,expiration_date ]
}
}
###########################################
### VMs & NICs ###
###########################################
### Windows
resource "azurerm_windows_virtual_machine" "win" {
name = local.vm_win_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
size = "Standard_B2ms"
admin_username = local.admin_username
admin_password = azurerm_key_vault_secret.password-win.value
network_interface_ids = [
azurerm_network_interface.win.id,
]
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
source_image_reference {
publisher = "MicrosoftWindowsServer"
offer = "WindowsServer"
sku = "2022-datacenter-g2"
version = "latest"
}
lifecycle {
ignore_changes = [ admin_password ]
}
}
resource "azurerm_network_interface" "win" {
name = "${local.vm_win_name}-nic"
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
ip_configuration {
name = "this"
subnet_id = azurerm_subnet.this.id
private_ip_address_allocation = "Dynamic"
}
}
### Linux
resource "azurerm_linux_virtual_machine" "unix" {
name = local.vm_unix_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
size = "Standard_B2ms"
admin_username = local.admin_username
admin_password = azurerm_key_vault_secret.password-unix.value
disable_password_authentication = false
admin_ssh_key {
username = local.admin_username
public_key = tls_private_key.ssh.public_key_openssh
}
network_interface_ids = [
azurerm_network_interface.unix.id,
]
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server"
version = "latest"
}
lifecycle {
ignore_changes = [ admin_ssh_key, admin_password ]
}
}
resource "azurerm_network_interface" "unix" {
name = "${local.vm_unix_name}-nic"
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
ip_configuration {
name = "this"
subnet_id = azurerm_subnet.this.id
private_ip_address_allocation = "Dynamic"
}
}