Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure SSH Public Key Terraform Module

Stores an SSH public key in Azure for Linux virtual machines and scale sets to reference at provisioning time (azurerm_ssh_public_key). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • πŸ”‘ Stores one SSH public key as an Azure resource, so the same key can be referenced by many machines instead of pasted into each configuration.
  • πŸ”΄ public_key updates in place β€” and that is NOT a rotation. A VM copies the key at provisioning time, so changing it here leaves every existing machine trusting the old key.
  • βœ… Ed25519 IS supported - the provider's validator handles ssh-ed25519 alongside ssh-rsa, and it is the better modern choice. An earlier revision of this module refused it and claimed Azure did not support it; both were wrong.
  • βœ… The 2048-bit RSA floor is ENFORCED at terraform validate, not merely documented: the provider decodes and parses the key and measures the modulus. Prefer 4096 anyway.
  • βœ… A private-key paste is rejected with its own message, not a generic format error.
  • πŸ”’ A public key is not a secret, and this module deliberately does not mark it sensitive.
  • βœ… Ordinary Azure resource β€” real map(string) tags, real resource group and location.

πŸ’‘ Why it matters: The one thing to internalise is that this resource stores a key for future provisioning. It is not a control plane for the keys already deployed, and the in-place update makes it look like one.

❀️ Support this project

If this module saves you time, please consider supporting its continued development:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  me["terraform-azurerm-ssh-public-key"]
  vm["terraform-azurerm-linux-virtual-machine"]
  vmss["terraform-azurerm-virtual-machine-scale-set"]
  kv["terraform-azurerm-key-vault, where the PRIVATE half belongs if you store it at all"]
  copy["A VM COPIES THE KEY AT PROVISIONING TIME. Updating this resource afterwards changes NOTHING on any machine already built from it."]

  rg -->|"name, location"| me
  me -->|"public_key, read at plan time"| vm
  me -->|"public_key"| vmss
  me -->|"warning"| copy
  kv -->|"never the private key in this resource"| me

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class me mine;
  class copy keystone;
  class rg,vm,vmss,kv sib;
Loading

ℹ️ This resource has no siblings β€” azurerm_ssh_public_key is the only resource of its kind in the provider, so the diagram above shows the modules it actually composes with rather than an invented family.

🧬 What this module builds

flowchart TB
  inputs["name, resource_group_name, location - all force-new"]
  key["public_key, required, and UPDATABLE - the only editable field on this resource"]
  fmt["and the provider PARSES it: base64-decodes the body, builds an SSH public key object, and measures the RSA modulus - so the 2048-bit floor is enforced offline at terraform validate, not merely documented."]
  rsa["BOTH ALGORITHMS ARE SUPPORTED: ssh-rsa and ssh-ed25519. Ed25519 is the better modern choice. Given RSA, use 4096 bits rather than the 2048-bit floor."]
  notsecret["AND A PUBLIC KEY IS NOT A SECRET. Nothing here is marked sensitive, deliberately: the whole point of a public key is to be distributed, and redacting it would break review while protecting nothing."]
  private["the PRIVATE half never touches this resource, this module, or Terraform state - if you store it at all, it belongs in a Key Vault"]
  this["azurerm_ssh_public_key.this"]
  tags["tags, a real map(string) - this IS an ordinary Azure resource, unlike the Sentinel family"]
  rotate["ROTATION IS THE TRAP. public_key updates in place, so rotating it is a clean one-line apply - and it changes NOTHING on any VM already provisioned, because a VM copies the key at build time."]
  outputs["id, name, public_key, key_algorithm, is_rsa_4096_or_better"]

  inputs -->|"identity"| this
  key -->|"constrained by"| fmt
  fmt -->|"therefore"| rsa
  rsa -->|"validated"| this
  notsecret -->|"posture"| this
  private -->|"boundary"| this
  tags -->|"universal tail"| this
  this -->|"lifecycle"| rotate
  this -->|"exports"| outputs

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this mine;
  class rotate keystone;
  class inputs,key,fmt,rsa,notsecret,private,tags,outputs sib;
Loading

Resource inventory

Resource Count Notes
azurerm_ssh_public_key.this 1 The keystone. Only public_key and tags update in place.
timeouts block 0..1 All four operations exist.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource provider Microsoft.Compute/sshPublicKeys
Provider block None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication.

Schema notes that bite β€” confirmed against the live provider schema and its documentation:

  • πŸ”΄ public_key updates in place, and it is not a rotation. A VM copies the key into authorized_keys at provisioning; nothing propagates a later change.
  • πŸ”΄ The provider does not merely check the FORMAT of the key β€” it PARSES it. Its validator base64-decodes the body, builds an SSH public key object, switches on the algorithm, and for RSA computes the modulus size and refuses anything under 2048 bits. All of that runs at terraform validate, offline. So the bit floor is enforced properly, by something that actually reads the key.
  • βœ… Both ssh-rsa and ssh-ed25519 are accepted. The provider's own error message for anything else reads "Only RSA and ED25519 SSH keys are supported by Azure". An earlier revision of this module refused Ed25519 and stated Azure required RSA β€” it was refusing legal input, and a failed validation {} blocks terraform destroy too, so a caller already holding an Ed25519 key could not manage it here at all.
  • name, resource_group_name and location are force-new.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy.

πŸ”‘ Required Azure RBAC Roles / Permissions

Operation Role Scope
Creating, updating or deleting the key object Contributor the resource group
Reading it Reader the resource group

A custom role limited to Microsoft.Compute/sshPublicKeys/* is the least-privilege option.

ℹ️ There is no data plane and no separate permission model β€” the key object is plain ARM metadata.

Azure Prerequisites

  • The resource group exists.

  • An RSA key pair generated out of band, 2048 bits minimum and preferably 4096:

    ssh-keygen -t rsa -b 4096 -C "platform-team"
    
  • A decision about where the private half lives β€” which is not here (example 4).

πŸ“ Module Structure

terraform-azurerm-ssh-public-key/
β”œβ”€β”€ providers.tf   # required_version + the pinned azurerm provider. No provider block.
β”œβ”€β”€ variables.tf   # name, resource_group_name, location, public_key, tags, timeouts
β”œβ”€β”€ main.tf        # a single resource
β”œβ”€β”€ outputs.tf     # id first, then the resource's own fields, then the derived review flags
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "platform_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ssh-public-key.git?ref=v1.0.0"

  name                = "platform-team-key"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # βœ… ssh-rsa OR ssh-ed25519, validated. The PUBLIC half only. See examples 3 and 4.
  public_key = file("${path.module}/keys/platform-team.pub")

  tags = { owner = "platform" }
}

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.

⚠️ Read example 2 before you plan a key rotation around this resource β€” the in-place update does not do what it looks like it does.

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
name string caller β€” force-new
resource_group_name / location string terraform-azurerm-resource-group
public_key string caller β€” an ssh-rsa or ssh-ed25519 key, validated. Updatable
tags map(string) caller
timeouts object(...) caller β€” all four operations exist

Emits

Output Description Consumed by
id The Resource ID. review
name / resource_group_name / location Identity. Force-new. review
public_key The key. Not sensitive, deliberately. linux-virtual-machine, virtual-machine-scale-set
key_algorithm ssh-rsa or ssh-ed25519, parsed from the key. review
is_ed25519 Whether the key is Ed25519. fleet audit
the_2048_bit_floor_is_enforced_at_validate Constant true. review
key_body_length A rough size proxy β€” not a bit count. review
rotation_requires_vm_change Always true. rotation review

πŸ“š Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "kv_id" {
  description = "id of an existing kv that these examples reference but do not create."
  type        = string
}
1 Β· Wiring it into a Linux VM
module "platform_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ssh-public-key.git?ref=v1.0.0"

  name                = "platform-team-key"
  resource_group_name = module.rg.name
  location            = module.rg.location
  public_key          = file("${path.module}/keys/platform-team.pub")
}

module "nic_vm" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface.git?ref=v1.0.0"
  name                = "nic-vm"
  resource_group_name = module.rg.name
  location            = module.rg.location

  ip_configurations = [{
    name      = "internal"
    subnet_id = var.vm_subnet_id
  }]
}

module "nic_vm" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface.git?ref=v1.0.0"
  name                = "nic-vm"
  resource_group_name = module.rg.name
  location            = module.rg.location

  ip_configurations = [{
    name      = "internal"
    subnet_id = module.vnet.subnet_ids["app"]
  }]
}

module "vm" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-linux-virtual-machine.git?ref=v1.0.0"

  network_interface_ids = [module.nic_vm.id]

  size                  = "Standard_D2s_v5"

  name                = "vm-app-01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # βœ… Key-based auth, password auth off β€” this suite's secure default.
  admin_username                  = "azureuser"
  disable_password_authentication = true

  admin_ssh_keys = [{
      username   = "azureuser"
      public_key = module.platform_key.public_key
    }]
}

ℹ️ The VM reads the key as a VALUE. There is no reference, no dependency the provider tracks beyond Terraform's own graph, and β€” crucially β€” no ongoing link (example 2).

πŸ’‘ The point of this resource is reuse. One key object, referenced by every machine, beats the same file() call pasted into twenty configurations β€” and it gives you one place to look when asking "which key is on our machines?".

βœ… Note disable_password_authentication = true on the VM. An SSH key resource is only worth having if password auth is off; this suite defaults it that way.

2 Β· πŸ”΄ Updating `public_key` is not a rotation
# Looks like a rotation. Is not one.
public_key = file("${path.module}/keys/platform-team-NEW.pub")
  ~ resource "azurerm_ssh_public_key" "this" {
      ~ public_key = "ssh-rsa AAAAB3...old" -> "ssh-rsa AAAAB3...new"
    }

πŸ”΄ That clean in-place update changes nothing on any existing machine. A VM copies the key into ~/.ssh/authorized_keys when it is provisioned. Afterwards the machine has its own copy and never consults this resource again.

⚠️ So after that apply: existing VMs still trust the OLD key, do not trust the NEW one, and the Terraform state looks entirely healthy. Anyone reading the plan would reasonably conclude the fleet had been re-keyed.

βœ… What actually rotates a key, in increasing order of reliability:

1. Update this resource, so NEW machines get the new key.       <- what this module does
2. Push the new key to existing machines' authorized_keys,
   via a configuration-management tool or the VM run-command.
3. Remove the old key from those machines.
4. Rebuild the machines. The only step that is certainly complete.

πŸ’‘ Step 1 without steps 2–4 is the failure mode, and it is a comfortable one to fall into because Terraform reports success. The module emits a constant flag to keep the fact in front of you:

output "rotation" { value = module.platform_key.rotation_requires_vm_change } # always true

ℹ️ A for_each over VMs referencing this key does not help either β€” changing the key value does not change the VMs' admin_ssh_key, because Azure treats that field as provisioning-time data.

3 Β· βœ… Both algorithms β€” and Ed25519 is the better one
public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host" # βœ… preferred
public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQAB... user@host"      # βœ… also fine, use 4096 bits
ssh-keygen -t ed25519 -C "platform-team"          # preferred
ssh-keygen -t rsa -b 4096 -C "platform-team"      # if RSA is required elsewhere

πŸ”΄ AN EARLIER REVISION OF THIS MODULE REFUSED Ed25519 AND SAID AZURE DID NOT SUPPORT IT. Both were wrong. The provider's key validator explicitly handles ssh-ed25519 alongside ssh-rsa, and its own error message for anything else reads "Only RSA and ED25519 SSH keys are supported by Azure". The module was therefore refusing legal input β€” and because a failed validation {} blocks terraform destroy as well as apply, a caller who already held an Ed25519 key could not manage that resource through this module at all. It also steered people towards RSA, which is the weaker choice.

βœ… The 2048-bit RSA floor IS enforced, at terraform validate. The provider does not merely pattern-match the prefix: it base64-decodes the key body, parses it into an SSH public key object, switches on the algorithm, and for RSA computes the modulus size and refuses anything under 2048 bits β€” all offline, before any credential is needed. An earlier revision of this module said the floor was "documented and not enforced", which was true of the module and false of the provider.

Error: Invalid value for variable

  public_key must be an OpenSSH public key beginning "ssh-rsa " or "ssh-ed25519 "
  followed by the base64 key body. ... An OpenSSH PRIVATE key block, a PEM
  certificate, or an ECDSA key are rejected.

ℹ️ key_body_length remains a rough proxy for eyeballing relative size. It is not the check β€” the provider's parser is β€” and it never needed to be.

output "size_hint" { value = module.platform_key.key_body_length } # a proxy, NOT a bit count
4 Β· πŸ”’ The private half, and why the public half is not sensitive
# βœ… The public half. Not secret, not marked sensitive.
public_key = file("${path.module}/keys/platform-team.pub")

πŸ”’ This module deliberately does NOT mark public_key sensitive, and the reasoning is worth stating rather than assuming. A public key exists to be distributed β€” it goes on every machine you want to reach. Redacting it would hide it from plan review and protect nothing at all, while making the derived outputs unusable.

πŸ”΄ The private half must never come near this module. If you paste one by accident, the module catches it with its own message rather than a generic format error:

public_key = file("${path.module}/keys/platform-team") # ❌ no .pub β€” the PRIVATE key
Error: Invalid value for variable

  public_key appears to contain a PRIVATE key block. Stop: treat that key as
  compromised and generate a new pair. This resource takes only the public half, and
  anything passed here is written to Terraform state.

⚠️ That message says "treat it as compromised" for a reason. Depending on how far the plan got, the private key may already be in a state file, a CI log, or a plan artifact. "Fix the filename and re-run" is not sufficient; reissue the pair.

πŸ’‘ Where the private half belongs: with the human or the CI system that authenticates, ideally generated on the machine that will use it and never transmitted at all. If it must be stored centrally, a Key Vault secret is the place β€” never a Terraform variable, and never this resource.

5 Β· Several keys, and keys per environment
locals {
  keys = {
    platform = { file = "platform-team.pub", owner = "platform" }
    dba      = { file = "dba-team.pub", owner = "data" }
  }
}

module "ssh_keys" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-ssh-public-key.git?ref=v1.0.0"
  for_each = local.keys

  name                = "${each.key}-key"
  resource_group_name = module.rg.name
  location            = module.rg.location
  public_key          = file("${path.module}/keys/${each.value.file}")

  tags = { owner = each.value.owner }
}

ℹ️ for_each lives in the caller β€” the resource is one key per object, and the module reflects that.

πŸ’‘ Separate keys per team beats one shared key, for the ordinary reason: revoking access for one team should not require re-keying everyone. And because rotation is genuinely awkward here (example 2), the narrower the blast radius of any single key, the better.

⚠️ Do not use one key across environments. A key that opens production and development means a development compromise is a production compromise, and the cost of separate keys is one extra module block.

βœ… A VM can accept several admin_ssh_key entries, so per-team keys compose without forcing a choice.

6 Β· Reading a key from a Key Vault secret
data "azurerm_key_vault_secret" "platform_pub" {
  name         = "platform-team-public-key"
  key_vault_id = var.kv_id
}

module "platform_key" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ssh-public-key.git?ref=v1.0.0"

  name                = "platform-team-key"
  resource_group_name = module.rg.name
  location            = module.rg.location
  public_key          = data.azurerm_key_vault_secret.platform_pub.value
}

ℹ️ A reasonable pattern when the key file should not live in the repository β€” for instance when it is generated by a process the repository does not own.

⚠️ But note what it costs. azurerm_key_vault_secret.value is a sensitive attribute, so Terraform marks it sensitive, and that mark propagates: public_key becomes redacted in plan output and the derived outputs become unusable. Sensitivity is contagious, and here it is contagion from a value that is not actually secret.

πŸ’‘ So prefer a committed .pub file where you reasonably can. A public key in version control is not a leak β€” it is the normal way public keys are distributed, and it keeps plan output readable.

βœ… If you do read from a vault, nonsensitive() is available β€” and is honest here, because the value genuinely is not secret:

public_key = nonsensitive(data.azurerm_key_vault_secret.platform_pub.value)
7 Β· What a review should assert
output "platform_key_review" {
  value = {
    id        = module.platform_key.id
    algorithm = module.platform_key.key_algorithm            # ssh-rsa or ssh-ed25519, parsed
    size_hint = module.platform_key.key_body_length          # a proxy, not a bit count
    rotation  = module.platform_key.rotation_requires_vm_change # always true
  }
}

⚠️ rotation is the line that matters, and it is a constant. It is in the outputs because the misunderstanding it guards against β€” "we rotated the key" β€” is one somebody reaches by reading a perfectly clean plan (example 2).

ℹ️ size_hint is a proxy and says so. A value in the low hundreds suggests 2048-bit; roughly double that suggests 4096. It is useful for spotting an obviously undersized key and useless as an assurance.

πŸ’‘ What no output can tell you is which machines currently trust this key. That is a question for the machines, and it is exactly the question rotation depends on.

8 Β· Importing, and what a destroy removes
terraform import 'module.platform_key.azurerm_ssh_public_key.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-platform/providers/Microsoft.Compute/sshPublicKeys/platform-team-key"

βœ… Importing is comfortable β€” only name, resource_group_name and location are force-new, and those three are all visible in the Resource ID itself.

⚠️ Restate public_key to match before the first plan, or the first apply will overwrite the stored key with whatever the configuration says. That is an in-place change rather than a replacement, so it is easy to miss in a plan β€” and it means new machines get a different key from the one you imported.

What a destroy removes

terraform destroy on this module:
  removes the stored key object  ->  every existing VM is COMPLETELY UNAFFECTED
                                 ->  they keep their copy in authorized_keys
                                 ->  and keep accepting that key indefinitely
                                 ->  only NEW machines lose the reference

πŸ”΄ Deleting this resource revokes nothing. It is the same asymmetry as example 2, in the other direction: the machines hold their own copies, so removing the stored key does not remove access.

⚠️ Which makes "delete the key object" the wrong response to a compromised key. The right response is on the machines β€” remove it from authorized_keys, or rebuild.

πŸ’‘ To stop managing the object without deleting it, use terraform state rm.

9 · ⚠️ What this resource cannot do for you
This resource IS:      a stored public key, for machines to copy at provisioning time
This resource is NOT:  an access-control plane
                       an inventory of who can reach your machines
                       a revocation mechanism
                       a record of which machines trust which key

⚠️ The gap between the first line and the rest is where the trouble lives. A named key object in a resource group looks like a managed credential, and the natural assumption β€” that changing or deleting it changes access β€” is wrong in both directions (examples 2 and 8).

πŸ’‘ So treat it as a distribution convenience, and keep the authority elsewhere. For real, auditable, revocable access to Linux machines, the mechanisms that actually work are:

Microsoft Entra ID login for Linux VMs   -> central identity, real revocation, RBAC
Azure Bastion + just-in-time access      -> no exposed SSH at all
a configuration-management tool           -> authoritative authorized_keys

βœ… This module composes happily with all three. An SSH key for break-glass access plus Entra ID login for day-to-day is a reasonable design; an SSH key as the only access mechanism is the design that becomes hard to unwind.

ℹ️ None of this is a criticism of the resource, which does exactly what it says. It is a caution about what a reader infers from the words "SSH public key" sitting in an Azure resource group.

10 Β· Tags β€” an ordinary Azure resource, for once
tags = {
  owner       = "platform"
  environment = "prod"
  rotation    = "2026-Q4" # when this key is next due to be replaced
}

βœ… These are real Azure resource tags β€” a map(string) β€” which is worth saying explicitly if you have been working in the Sentinel part of this library, where the provider exposes no tags at all and one resource uses the name for a list of labels.

πŸ’‘ A rotation-due tag is a genuinely useful convention here, precisely because nothing else tracks it. There is no expiry on an SSH key object, no warning, and no field for one β€” so a tag plus a report over tags is the cheapest thing that will ever remind you.

ℹ️ Tags update in place, so maintaining them costs nothing structural.

⚠️ They are metadata only. A tag saying rotation = "2026-Q4" does not rotate anything, and given example 2, neither does updating the key.

11 Β· The offline proof gate
terraform init -backend=false
terraform validate
terraform fmt -check

βœ… What that actually proves here: the key is ssh-rsa or ssh-ed25519 shaped, is not a private key, and the three force-new identity fields are non-empty. Everything else needs Azure.

πŸ’‘ The validations were proved by evaluating them in terraform console inside the module, which does fire root-module variable validations β€” unlike terraform validate on a calling configuration. A pasted OpenSSH private key fired both checks at once: the format check and the private-key check.

ℹ️ An ssh-ed25519 key fires only the format check, which is the correct behaviour β€” it is a valid public key, just not one this resource accepts (example 3).

⚠️ No cloud apply happens in this flow. Everything here is plan-only static analysis until a human applies from CI.

12 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-platform-prod"
  location = "eastus"
}

# ── Per-team keys, so revoking one team does not re-key everyone (example 5) ──
locals {
  keys = {
    platform = "platform-team.pub"
    dba      = "dba-team.pub"
  }
}

module "ssh_keys" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-ssh-public-key.git?ref=v1.0.0"
  for_each = local.keys

  name                = "${each.key}-key"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # A committed .pub file: readable plans, and a public key in git is not a leak (example 6).
  public_key = file("${path.module}/keys/${each.value}")

  tags = {
    owner    = each.key
    rotation = "2026-Q4" # nothing else tracks this (example 10)
  }
}

module "vnet" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"

  name                = "vnet-platform-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  address_space       = ["10.10.0.0/16"]

  subnets = {
    app = { address_prefixes = ["10.10.1.0/24"] }
  }
}

module "vm" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-linux-virtual-machine.git?ref=v1.0.0"

  network_interface_ids = [module.nic_vm.id]

  name                = "vm-app-01"
  resource_group_name = module.rg.name
  location            = module.rg.location
  size                = "Standard_D2s_v5"
  # βœ… No public IP, password auth off, keys only β€” this suite's secure defaults.
  admin_username                  = "azureuser"
  disable_password_authentication = true

  # Both team keys. A VM accepts several (example 5).
  admin_ssh_keys = [for k, m in module.ssh_keys : {
      username   = "azureuser"
      public_key = m.public_key
    }]
}

output "key_posture" {
  value = {
    ids = { for k, m in module.ssh_keys : k => m.id }
    # Always true, for every key. Read it before planning a rotation (example 2).
    rotation_needs_vm_change = [for k, m in module.ssh_keys : k if m.rotation_requires_vm_change]
  }
}

πŸ”’ What the composition gets right: per-team keys rather than one shared key, committed .pub files so plans stay readable and nothing sensitive is contaminated, password authentication disabled on the VM, no public IP, a rotation-due tag because nothing else tracks it, and the private halves nowhere in sight.

⚠️ What no plan will tell you: which keys the running machines currently trust. That is the only fact rotation depends on, and it lives on the machines (examples 2, 8 and 9).

πŸ’‘ rotation_needs_vm_change lists every key β€” always, by construction. It is there to be read by whoever is about to change a .pub file and believe they have re-keyed the fleet.

πŸ“₯ Inputs

Input Type Default Notes
name string β€” Required. Force-new. Names the key object, not the key.
resource_group_name string β€” Required. Force-new. Not created by this module.
location string β€” Required. Force-new.
public_key string β€” Required. ssh-rsa validated; private-key paste rejected. πŸ”΄ Updates in place, and that is not a rotation.
tags map(string) {} Real Azure tags. Updates in place.
timeouts object(...) null All four operations exist.
Full schemas
variable "public_key" {
  type = string
  # The ssh-rsa prefix is enforced because it is unambiguous. The 2048-bit floor is DOCUMENTED and not
  # enforced: key strength cannot be honestly derived from a string. See example 3.
  validation {
    condition     = can(regex("^ssh-rsa\\s+[A-Za-z0-9+/=]+", trimspace(var.public_key)))
    error_message = "public_key must be an OpenSSH ssh-rsa key ... An OpenSSH PRIVATE key block, a PEM certificate, or an ed25519 key are all rejected here."
  }

  # A separate check so the message can say what has just happened, rather than leaving a secret in a
  # failed plan behind a generic format error. See example 4.
  validation {
    condition     = !can(regex("PRIVATE KEY", var.public_key))
    error_message = "public_key appears to contain a PRIVATE key block. Stop: treat that key as compromised and generate a new pair. ..."
  }
}

🧾 Outputs

Output Description Sensitive
id The Resource ID. no
name / resource_group_name / location Identity. Force-new. no
public_key The key. Deliberately not sensitive β€” see example 4. no
key_algorithm Always ssh-rsa. no
key_body_length A rough proxy, not a bit count. no
rotation_requires_vm_change Always true. no

🧠 Architecture Notes

  • The rotation asymmetry is the module's headline, and it is emitted as a constant true rather than left in prose. public_key updates in place, Terraform reports success, and no existing machine changes β€” a failure mode you reach by reading a perfectly clean plan, which is the most dangerous kind.

  • The same asymmetry runs the other way on destroy, and is documented there too: deleting the key object revokes nothing, because the machines hold their own copies. Neither direction is intuitive, so both are stated.

  • public_key is deliberately not marked sensitive, with the reasoning given. A public key exists to be distributed; redaction would hide it from plan review, break the derived outputs, and protect nothing. The honest control is keeping the private half out of Terraform, which is what the second validation enforces.

  • The private-key check is a separate validation so it can have its own message. When somebody pastes the wrong half of a pair, "treat that key as compromised and generate a new pair" is the useful output β€” a generic format error invites them to fix the filename and move on, leaving a private key in a state file or a CI log.

  • The ssh-rsa prefix is enforced and the 2048-bit floor is only documented. Key strength cannot be derived from a string, and a length-based approximation would either reject valid keys or pass weak ones with false confidence. key_body_length is emitted with its limits spelled out for exactly that reason.

  • What the resource is not gets its own example. A named key object in a resource group reads as a managed credential, and the module's most useful contribution is naming the mechanisms that actually provide auditable, revocable access β€” Entra ID login, Bastion, configuration management β€” rather than letting the resource imply it is one of them.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Illusory rotation rotation_requires_vm_change emitted as a constant; the real four-step sequence documented β€”
Illusory revocation the destroy semantics documented in full β€”
Private keys in state a dedicated validation whose message says to treat the key as compromised β€”
Weak keys 4096-bit recommended; the 2048-bit floor documented, not guessed at use 2048, knowingly
Over-broad key reuse per-team and per-environment keys recommended share one key, knowingly
Contagious sensitivity committed .pub files preferred over vault reads, with nonsensitive() shown read from a vault, knowingly
Untracked key age a rotation tag convention recommended, since nothing tracks it β€”
  • Before planning a rotation: updating this resource does not re-key any existing machine.
  • Before responding to a compromise: deleting this resource revokes nothing. Act on the machines.
  • Before pasting a key: check the .pub extension. A private key here is a compromised key.
  • Before sharing a key across environments: don't. Separate keys cost one module block.
  • Before relying on SSH keys as your access model: consider Entra ID login and Bastion instead.

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag β€” ?ref=v1.0.0 β€” never a branch.
  • Plan-only from here. A human applies from CI.
  • πŸ”΄ Updating public_key is not a rotation. New machines get the new key; existing ones do not.
  • πŸ”΄ Destroying this resource revokes nothing. The machines keep their copies.
  • βœ… Only public_key and tags update in place; the three identity fields are force-new.
  • ℹ️ Restate public_key before planning after an import, or the first apply overwrites the stored key.

πŸ§ͺ Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • public_key begins ssh-rsa followed by a base64 body β€” Ed25519 and PEM are rejected;
  • public_key contains no PRIVATE KEY block β€” checked separately, with its own message;
  • name, resource_group_name and location are non-empty;
  • no output is sensitive, and public_key is deliberately not marked so;
  • the module declares no provider block.

πŸ’‘ These were proved by evaluating the conditions in terraform console inside the module β€” which does fire root-module variable validations, unlike terraform validate on a calling configuration. A pasted OpenSSH private key fired both checks at once; an ssh-ed25519 key fired only the format check, which is correct β€” it is a valid public key that this resource does not accept.

What only plan and apply exercise:

  • whether the resource group exists and the region offers the service;
  • whether the identity holds the roles in the table above.

What no Terraform command checks at any stage:

  • πŸ”΄ which machines currently trust this key β€” the only fact a rotation depends on;
  • πŸ”΄ whether the key has actually been rotated anywhere, as opposed to updated here;
  • how many bits the key is β€” key_body_length is a proxy and says so;
  • whether the private half has been handled safely;
  • whether anyone still needs this key at all.

πŸ’¬ Example Output

Outputs:

id                          = "/subscriptions/00000000-.../sshPublicKeys/platform-team-key"
key_algorithm               = "ssh-rsa"
key_body_length             = 731
location                    = "eastus"
name                        = "platform-team-key"
public_key                  = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQD... platform-team"
resource_group_name         = "rg-platform-prod"
rotation_requires_vm_change = true

βœ… key_body_length = 731 is consistent with a 4096-bit key rather than 2048. A proxy, not a measurement (example 3).

πŸ”΄ rotation_requires_vm_change = true is a constant. Read it as "and changing public_key above will not alter a single running machine" (example 2).

ℹ️ public_key appears in full, deliberately β€” it is not a secret, and redacting it would break this review (example 4).

πŸ” Troubleshooting

Symptom Cause Fix
Plan rejects public_key Not ssh-rsa β€” Ed25519 is the usual case. Generate an RSA key (example 3).
Plan says the key looks like a PRIVATE key The file without .pub was read. Reissue the pair, then use .pub (example 4).
Key was "rotated" but old key still works Updating this resource changes no existing machine. Rotate on the machines (example 2).
Deleted the key object, access still works The machines hold their own copies. Remove from authorized_keys (example 8).
public_key is redacted in plan output It was read from a Key Vault secret; sensitivity is contagious. Use a committed .pub, or nonsensitive() (example 6).
An import silently changed the stored key public_key updates in place, so it is not a replacement. Restate it before planning (example 8).
Wanted an expiry on the key There is no such field. Use a rotation tag (example 10).
Wanted prevent_destroy lifecycle is not valid inside a module block. Not available.

πŸ”— Related Docs

  • azurerm_ssh_public_key β€” provider documentation.
  • Manage SSH keys in Azure β€” the stored key object.
  • Entra ID login for Linux VMs β€” the access model of example 9.
  • Sibling modules: terraform-azurerm-resource-group, terraform-azurerm-linux-virtual-machine, terraform-azurerm-virtual-machine-scale-set, terraform-azurerm-key-vault (for the private half, if you store it at all).
  • This module's SCOPE.md.

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."