IaC Azure

Terraform Module: Azure Automation Account — Managed-identity runbook automation with a hardened default

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Automation Account: system-assigned identity, public-access lockdown, diagnostic logging, and ready-to-attach modules and schedules. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "automation_account" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-automation-account?ref=v1.0.0"

  name                = "..."  # Automation Account name (6-50 chars, starts with a lett…
  resource_group_name = "..."  # Resource group to create the account in.
  location            = "..."  # Azure region (e.g. `centralindia`).
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An Azure Automation Account is the control-plane container for process automation in Azure: it hosts PowerShell/Python runbooks, schedules, variables, credentials, connections, and the Hybrid Runbook Worker registration that lets you run jobs against on-prem or other-cloud targets. It is the workhorse behind unattended operations like nightly VM start/stop, certificate rotation, tag remediation, and patching orchestration.

Created by hand, an Automation Account tends to drift: someone enables the legacy Run As account (now deprecated), leaves public_network_access wide open, picks the wrong SKU, or forgets to wire diagnostics so runbook job logs vanish. Wrapping azurerm_automation_account in a module bakes the safe defaults in once — system-assigned managed identity instead of Run As, public access disabled by default, a consistent SKU, and a single diagnostic-settings hook — so every team that consumes it inherits the same hardened baseline and the same naming convention. The module also exposes the account’s identity[0].principal_id so callers can grant that identity exactly the RBAC it needs (e.g. Virtual Machine Contributor on a resource group) without ever touching a stored credential.

When to use it

Skip it for true event-driven, sub-second workloads — reach for Azure Functions or Logic Apps instead. Automation Account shines for scheduled and long-running operational tasks.

Module structure

terraform-module-azure-automation-account/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  # Run As accounts are deprecated/retired; the module standardises on managed identity.
  identity_type = var.user_assigned_identity_ids == null || length(var.user_assigned_identity_ids) == 0 ? "SystemAssigned" : "SystemAssigned, UserAssigned"

  base_tags = merge(
    {
      "managed-by" = "terraform"
      "module"     = "terraform-module-azure-automation-account"
    },
    var.tags
  )
}

resource "azurerm_automation_account" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location
  sku_name            = var.sku_name

  # Lock down the data-plane endpoint by default; callers opt in to public access.
  public_network_access_enabled = var.public_network_access_enabled
  local_authentication_enabled  = var.local_authentication_enabled

  identity {
    type         = local.identity_type
    identity_ids = var.user_assigned_identity_ids
  }

  dynamic "encryption" {
    for_each = var.customer_managed_key == null ? [] : [var.customer_managed_key]
    content {
      key_vault_key_id          = encryption.value.key_vault_key_id
      user_assigned_identity_id = encryption.value.user_assigned_identity_id
    }
  }

  tags = local.base_tags
}

# --- Common production sub-resources ---------------------------------------

# Module-scoped variables (e.g. target resource group, time zone) consumed by runbooks.
resource "azurerm_automation_variable_string" "this" {
  for_each = var.string_variables

  name                    = each.key
  resource_group_name     = var.resource_group_name
  automation_account_name = azurerm_automation_account.this.name
  value                   = each.value.value
  description             = each.value.description
  encrypted               = each.value.encrypted
}

# Schedules that runbooks can be linked to (created separately by the caller).
resource "azurerm_automation_schedule" "this" {
  for_each = var.schedules

  name                    = each.key
  resource_group_name     = var.resource_group_name
  automation_account_name = azurerm_automation_account.this.name
  frequency               = each.value.frequency
  interval                = each.value.interval
  timezone                = each.value.timezone
  start_time              = each.value.start_time
  description             = each.value.description
  week_days               = each.value.week_days
  month_days              = each.value.month_days
}

# Optional: ship job logs/metrics to a Log Analytics workspace.
resource "azurerm_monitor_diagnostic_setting" "this" {
  count = var.log_analytics_workspace_id == null ? 0 : 1

  name                       = "diag-to-law"
  target_resource_id         = azurerm_automation_account.this.id
  log_analytics_workspace_id = var.log_analytics_workspace_id

  enabled_log {
    category = "JobLogs"
  }

  enabled_log {
    category = "JobStreams"
  }

  enabled_log {
    category = "DscNodeStatus"
  }

  metric {
    category = "AllMetrics"
  }
}

variables.tf

variable "name" {
  description = "Name of the Automation Account. Must be 6-50 chars, alphanumerics and hyphens, start with a letter and end alphanumeric."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$", var.name))
    error_message = "name must be 6-50 chars: start with a letter, end alphanumeric, only letters/digits/hyphens."
  }
}

variable "resource_group_name" {
  description = "Resource group in which to create the Automation Account."
  type        = string
}

variable "location" {
  description = "Azure region for the Automation Account (e.g. centralindia, eastus)."
  type        = string
}

variable "sku_name" {
  description = "Automation Account SKU. 'Basic' includes 500 free job minutes/month; 'Free' is capped and unsuitable for production."
  type        = string
  default     = "Basic"

  validation {
    condition     = contains(["Basic", "Free"], var.sku_name)
    error_message = "sku_name must be either 'Basic' or 'Free'."
  }
}

variable "public_network_access_enabled" {
  description = "Whether the Automation Account data-plane endpoints are reachable over public network. Defaults to false (use Private Link)."
  type        = bool
  default     = false
}

variable "local_authentication_enabled" {
  description = "Allow local (key-based) authentication to the account. Defaults to false to force Entra ID / managed-identity auth."
  type        = bool
  default     = false
}

variable "user_assigned_identity_ids" {
  description = "Optional list of user-assigned managed identity resource IDs to attach in addition to the system-assigned identity."
  type        = list(string)
  default     = null
}

variable "customer_managed_key" {
  description = "Optional customer-managed key (CMK) config for encryption at rest. Set null to use Microsoft-managed keys."
  type = object({
    key_vault_key_id          = string
    user_assigned_identity_id = optional(string)
  })
  default = null
}

variable "string_variables" {
  description = "Map of Automation string variables to create (key = variable name)."
  type = map(object({
    value       = string
    description = optional(string)
    encrypted   = optional(bool, false)
  }))
  default = {}
}

variable "schedules" {
  description = "Map of Automation schedules to create (key = schedule name). start_time must be RFC3339 and at least 5 minutes in the future."
  type = map(object({
    frequency   = string
    interval    = optional(number, 1)
    timezone    = optional(string, "Etc/UTC")
    start_time  = optional(string)
    description = optional(string)
    week_days   = optional(list(string))
    month_days  = optional(list(number))
  }))
  default = {}

  validation {
    condition = alltrue([
      for s in values(var.schedules) :
      contains(["OneTime", "Day", "Hour", "Week", "Month"], s.frequency)
    ])
    error_message = "Each schedule frequency must be one of: OneTime, Day, Hour, Week, Month."
  }
}

variable "log_analytics_workspace_id" {
  description = "Optional Log Analytics workspace resource ID to send JobLogs/JobStreams/DscNodeStatus and metrics to. Set null to skip diagnostics."
  type        = string
  default     = null
}

variable "tags" {
  description = "Additional tags merged onto the Automation Account."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Automation Account."
  value       = azurerm_automation_account.this.id
}

output "name" {
  description = "Name of the Automation Account."
  value       = azurerm_automation_account.this.name
}

output "principal_id" {
  description = "Object (principal) ID of the system-assigned managed identity. Grant this RBAC on target scopes."
  value       = azurerm_automation_account.this.identity[0].principal_id
}

output "tenant_id" {
  description = "Tenant ID of the system-assigned managed identity."
  value       = azurerm_automation_account.this.identity[0].tenant_id
}

output "dsc_server_endpoint" {
  description = "DSC server endpoint used by State Configuration nodes."
  value       = azurerm_automation_account.this.dsc_server_endpoint
}

output "dsc_primary_access_key" {
  description = "Primary access key for the DSC server endpoint (sensitive)."
  value       = azurerm_automation_account.this.dsc_primary_access_key
  sensitive   = true
}

output "schedule_ids" {
  description = "Map of created schedule names to their resource IDs."
  value       = { for k, s in azurerm_automation_schedule.this : k => s.id }
}

How to use it

module "automation_account" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-automation-account?ref=v1.0.0"

  name                = "aa-ops-prod-cin"
  resource_group_name = azurerm_resource_group.ops.name
  location            = azurerm_resource_group.ops.location
  sku_name            = "Basic"

  # Hardened defaults are already off; keep public access disabled and use Private Link.
  public_network_access_enabled = false
  local_authentication_enabled  = false

  string_variables = {
    "TargetResourceGroup" = {
      value       = "rg-vmss-prod"
      description = "Resource group the start/stop runbook operates on."
    }
  }

  schedules = {
    "stop-vms-nightly" = {
      frequency   = "Day"
      interval    = 1
      timezone    = "Asia/Kolkata"
      start_time  = "2026-06-10T20:00:00+05:30"
      description = "Stop non-prod VMs every night at 20:00 IST."
    }
  }

  log_analytics_workspace_id = azurerm_log_analytics_workspace.ops.id

  tags = {
    environment = "prod"
    owner       = "platform-ops"
  }
}

# Downstream: grant the account's managed identity the rights its runbooks need.
resource "azurerm_role_assignment" "vm_operator" {
  scope                = azurerm_resource_group.workloads.id
  role_definition_name = "Virtual Machine Contributor"
  principal_id         = module.automation_account.principal_id
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/automation_account/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-automation-account?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/automation_account && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Automation Account name (6-50 chars, starts with a letter, ends alphanumeric).
resource_group_name string Yes Resource group to create the account in.
location string Yes Azure region (e.g. centralindia).
sku_name string "Basic" No SKU: Basic or Free.
public_network_access_enabled bool false No Expose data-plane endpoints publicly; keep false and use Private Link.
local_authentication_enabled bool false No Allow key-based local auth; false forces Entra ID / managed identity.
user_assigned_identity_ids list(string) null No Extra user-assigned identity IDs to attach alongside the system-assigned one.
customer_managed_key object null No CMK config (key_vault_key_id, optional user_assigned_identity_id) for encryption at rest.
string_variables map(object) {} No Automation string variables to create (supports encrypted).
schedules map(object) {} No Schedules to create (frequency, interval, timezone, start_time, etc.).
log_analytics_workspace_id string null No Log Analytics workspace ID for diagnostic settings; null skips diagnostics.
tags map(string) {} No Additional tags merged onto the account.

Outputs

Name Description
id Resource ID of the Automation Account.
name Name of the Automation Account.
principal_id Object ID of the system-assigned managed identity (grant RBAC to this).
tenant_id Tenant ID of the system-assigned managed identity.
dsc_server_endpoint DSC server endpoint for State Configuration nodes.
dsc_primary_access_key Primary DSC access key (sensitive).
schedule_ids Map of created schedule names to their resource IDs.

Enterprise scenario

A retail platform team runs 200+ non-production VMs across six subscription landing zones and burns budget overnight when they sit idle. They deploy this module once per landing zone via a Terragrunt fan-out, each with a stop-vms-nightly and start-vms-morning schedule in Asia/Kolkata, and grant each account’s principal_id the Virtual Machine Contributor role scoped only to that zone’s workload resource groups. Runbooks authenticate with the system-assigned identity (no Run As certificates to rotate), and every job’s JobLogs and JobStreams flow into a central Log Analytics workspace, giving the platform team one query surface to confirm the nightly shutdown actually fired and to alert when it doesn’t.

Best practices

TerraformAzureAutomation AccountModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading