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
- You run scheduled operational automation (start/stop, snapshot cleanup, scaling) and want it codified, not click-built.
- You need a managed identity that runbooks authenticate with, replacing the deprecated Run As / classic Run As accounts.
- You want Update Management, Change Tracking, or State Configuration (DSC) anchored to a governed account.
- You manage many landing zones and need an identical, policy-friendly Automation Account in each subscription.
- You are decommissioning ad-hoc scripts on a jump box and moving them into source-controlled runbooks with audit logging.
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 config — live/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 config — live/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
- Use the system-assigned managed identity, not Run As. Classic and modern Run As accounts are retired; this module defaults to a managed identity and exposes
principal_idso you grant least-privilege RBAC per target scope instead of storing certificates. - Keep
public_network_access_enabled = falseand front the account with Private Link. Combined withlocal_authentication_enabled = false, this forces Entra ID auth and removes the key-leak blast radius. - Always wire
log_analytics_workspace_id. Without diagnostic settings,JobLogs/JobStreamsare short-lived in the portal; centralizing them is essential for audit and for alerting when a scheduled job silently fails. - Right-size the SKU and watch job minutes.
Basicgives 500 free job minutes/month before per-minute billing; reserveFreefor sandboxes only, and consolidate low-frequency runbooks rather than spinning up extra accounts. - Store secrets as encrypted Automation variables or in Key Vault, never inline in runbooks. Set
encrypted = trueon sensitivestring_variables, or have runbooks pull from Key Vault using the same managed identity. - Name consistently and tag for ownership. Follow a
aa-<purpose>-<env>-<region>convention and always populateowner/environmenttags so cost and incident routing are unambiguous across landing zones.