IaC Azure

Terraform Module: Azure Log Analytics Workspace — One Workspace, Governed Retention and Cost Caps

Quick take — A production-ready hashicorp/azurerm ~> 4.0 module for azurerm_log_analytics_workspace: pinned SKU, per-table retention, daily ingestion caps, and diagnostic-ready outputs for centralized Azure logging. 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 "log_analytics" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-log-analytics?ref=v1.0.0"

  name                = "..."  # Workspace name; 4-63 chars, alphanumeric/hyphen, must s…
  location            = "..."  # Azure region for the workspace (e.g. `centralindia`).
  resource_group_name = "..."  # Resource group that holds the workspace.
}

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

What this module is

An Azure Log Analytics Workspace is the storage and query engine behind Azure Monitor. It is where platform diagnostic logs, VM and container metrics, Sentinel security events, Application Insights telemetry, and custom KQL-queryable tables all land. Almost every other Azure resource that emits logs needs a workspace ID to point its diagnostic settings at, which makes the workspace a foundational dependency for an entire landing zone rather than a standalone component.

Wrapping azurerm_log_analytics_workspace in a reusable module matters because the defaults are wrong for production in two expensive ways. First, billing: the PerGB2018 SKU charges per gigabyte ingested with no ceiling unless you explicitly set a daily quota, so an unbounded workspace plus a chatty application can quietly produce a five-figure monthly bill. Second, retention: the platform default is 30 days, but compliance regimes frequently demand 90, 180, or longer, and getting that right per-table (rather than paying long-term rates for noisy verbose tables) is fiddly to do by hand. This module bakes a sane retention default, an optional daily cap, optional per-table retention overrides, and a customer-managed-key-ready configuration into one versioned interface, and exports the IDs that every downstream diagnostic setting will consume.

When to use it

Reach for a different approach if you only need ephemeral container stdout for a dev cluster (Container Insights basic logs may be cheaper) or if a team genuinely needs an isolated, throwaway workspace with no governance — in that case the raw resource is fine.

Module structure

terraform-module-azure-log-analytics/
├── 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
resource "azurerm_log_analytics_workspace" "this" {
  name                = var.name
  location            = var.location
  resource_group_name = var.resource_group_name

  sku               = var.sku
  retention_in_days = var.retention_in_days

  # -1 means "no cap"; azurerm rejects a positive quota on the Free SKU.
  daily_quota_gb = var.daily_quota_gb

  # Ingestion/query over the public internet can be disabled for private-link-only setups.
  internet_ingestion_enabled = var.internet_ingestion_enabled
  internet_query_enabled     = var.internet_query_enabled

  # When true, granular per-table RBAC is enforced instead of workspace-wide access.
  local_authentication_disabled       = var.local_authentication_disabled
  reservation_capacity_in_gb_per_day  = var.sku == "CapacityReservation" ? var.reservation_capacity_in_gb_per_day : null

  tags = var.tags
}

# Per-table retention overrides (e.g. keep security tables longer, trim noisy ones).
resource "azurerm_log_analytics_workspace_table" "this" {
  for_each = var.table_retention

  workspace_id            = azurerm_log_analytics_workspace.this.id
  name                    = each.key
  retention_in_days       = each.value.retention_in_days
  total_retention_in_days = each.value.total_retention_in_days
  plan                    = each.value.plan
}

# Optional: pin solutions (e.g. SecurityInsights for Sentinel, ContainerInsights for AKS).
resource "azurerm_log_analytics_solution" "this" {
  for_each = toset(var.solutions)

  solution_name         = each.value
  location              = var.location
  resource_group_name   = var.resource_group_name
  workspace_resource_id = azurerm_log_analytics_workspace.this.id
  workspace_name        = azurerm_log_analytics_workspace.this.name

  plan {
    publisher = "Microsoft"
    product   = "OMSGallery/${each.value}"
  }
}
# variables.tf
variable "name" {
  type        = string
  description = "Name of the Log Analytics Workspace. 4-63 chars, alphanumeric and hyphens, must start/end alphanumeric."

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{2,61}[a-zA-Z0-9]$", var.name))
    error_message = "name must be 4-63 chars, alphanumeric/hyphen, starting and ending with an alphanumeric character."
  }
}

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

variable "resource_group_name" {
  type        = string
  description = "Name of the resource group that will hold the workspace."
}

variable "sku" {
  type        = string
  default     = "PerGB2018"
  description = "Workspace pricing SKU. Use PerGB2018 unless you have a committed-tier reservation."

  validation {
    condition     = contains(["Free", "PerNode", "Premium", "Standard", "Standalone", "Unlimited", "CapacityReservation", "PerGB2018", "LACluster"], var.sku)
    error_message = "sku must be a valid Log Analytics SKU; modern workspaces use PerGB2018 or CapacityReservation."
  }
}

variable "retention_in_days" {
  type        = number
  default     = 90
  description = "Default interactive retention in days. PerGB2018 supports 30-730; Free SKU is fixed at 7."

  validation {
    condition     = var.retention_in_days == 7 || (var.retention_in_days >= 30 && var.retention_in_days <= 730)
    error_message = "retention_in_days must be 7 (Free) or between 30 and 730."
  }
}

variable "daily_quota_gb" {
  type        = number
  default     = -1
  description = "Daily ingestion cap in GB. -1 disables the cap. Set a positive value to protect the budget."

  validation {
    condition     = var.daily_quota_gb == -1 || var.daily_quota_gb >= 0.023
    error_message = "daily_quota_gb must be -1 (uncapped) or at least 0.023 GB."
  }
}

variable "internet_ingestion_enabled" {
  type        = bool
  default     = true
  description = "Allow log ingestion over the public internet. Set false for private-link-only ingestion."
}

variable "internet_query_enabled" {
  type        = bool
  default     = true
  description = "Allow querying over the public internet. Set false for private-link-only access."
}

variable "local_authentication_disabled" {
  type        = bool
  default     = false
  description = "Disable shared-key (workspace) auth and require Azure AD / table-level RBAC."
}

variable "reservation_capacity_in_gb_per_day" {
  type        = number
  default     = 100
  description = "Committed daily capacity in GB. Only applied when sku = CapacityReservation. Valid steps: 100,200,300,400,500,1000,2000,5000."

  validation {
    condition     = contains([100, 200, 300, 400, 500, 1000, 2000, 5000], var.reservation_capacity_in_gb_per_day)
    error_message = "reservation_capacity_in_gb_per_day must be one of 100,200,300,400,500,1000,2000,5000."
  }
}

variable "table_retention" {
  type = map(object({
    retention_in_days       = optional(number)
    total_retention_in_days = optional(number)
    plan                    = optional(string, "Analytics")
  }))
  default     = {}
  description = "Per-table retention overrides keyed by table name (e.g. SecurityEvent, ContainerLogV2)."

  validation {
    condition = alltrue([
      for t in values(var.table_retention) :
      t.plan == null || contains(["Analytics", "Basic"], t.plan)
    ])
    error_message = "Each table plan must be either Analytics or Basic."
  }
}

variable "solutions" {
  type        = list(string)
  default     = []
  description = "OMS Gallery solutions to enable (e.g. SecurityInsights, ContainerInsights, VMInsights)."
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Tags applied to the workspace and solutions."
}
# outputs.tf
output "id" {
  value       = azurerm_log_analytics_workspace.this.id
  description = "Resource ID of the workspace. Use this as workspace_id in azurerm_monitor_diagnostic_setting."
}

output "name" {
  value       = azurerm_log_analytics_workspace.this.name
  description = "Name of the workspace."
}

output "workspace_id" {
  value       = azurerm_log_analytics_workspace.this.workspace_id
  description = "Customer/workspace GUID (the 'Workspace ID' shown in the portal), used by agents and APIs."
}

output "primary_shared_key" {
  value       = azurerm_log_analytics_workspace.this.primary_shared_key
  description = "Primary shared key for agent enrollment. Sensitive — prefer Azure AD auth where possible."
  sensitive   = true
}

output "secondary_shared_key" {
  value       = azurerm_log_analytics_workspace.this.secondary_shared_key
  description = "Secondary shared key, useful for key rotation."
  sensitive   = true
}

output "table_ids" {
  value       = { for k, t in azurerm_log_analytics_workspace_table.this : k => t.id }
  description = "Map of table name to table resource ID for any per-table retention overrides."
}

How to use it

module "log_analytics_workspace" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-log-analytics?ref=v1.0.0"

  name                = "law-platform-prod-cin"
  location            = "centralindia"
  resource_group_name = azurerm_resource_group.monitoring.name

  sku               = "PerGB2018"
  retention_in_days = 90

  # Hard ceiling so a runaway workload cannot blow the monthly monitoring budget.
  daily_quota_gb = 25

  # Private-link-only landing zone: no public ingestion or query.
  internet_ingestion_enabled    = false
  internet_query_enabled        = false
  local_authentication_disabled = true

  # Keep security data long, trim verbose container logs short.
  table_retention = {
    SecurityEvent = {
      retention_in_days       = 180
      total_retention_in_days = 365
    }
    ContainerLogV2 = {
      retention_in_days = 30
      plan              = "Basic"
    }
  }

  solutions = ["SecurityInsights", "ContainerInsights"]

  tags = {
    environment = "prod"
    owner       = "platform-team"
    cost_center = "monitoring"
  }
}

# Downstream: route an AKS cluster's diagnostics into the workspace using its output.
resource "azurerm_monitor_diagnostic_setting" "aks" {
  name                       = "aks-to-law"
  target_resource_id         = azurerm_kubernetes_cluster.main.id
  log_analytics_workspace_id = module.log_analytics_workspace.id

  enabled_log {
    category = "kube-apiserver"
  }

  enabled_log {
    category = "kube-audit"
  }

  metric {
    category = "AllMetrics"
  }
}

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/log_analytics/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

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

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

cd live/prod/log_analytics && 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 Workspace name; 4-63 chars, alphanumeric/hyphen, must start and end alphanumeric.
location string Yes Azure region for the workspace (e.g. centralindia).
resource_group_name string Yes Resource group that holds the workspace.
sku string "PerGB2018" No Pricing SKU; PerGB2018 for pay-as-you-go or CapacityReservation for committed tiers.
retention_in_days number 90 No Default interactive retention; 7 (Free) or 30-730.
daily_quota_gb number -1 No Daily ingestion cap in GB; -1 disables it.
internet_ingestion_enabled bool true No Allow ingestion over the public internet.
internet_query_enabled bool true No Allow querying over the public internet.
local_authentication_disabled bool false No Disable shared-key auth and require Azure AD / table RBAC.
reservation_capacity_in_gb_per_day number 100 No Committed daily capacity (GB); only used when sku = CapacityReservation.
table_retention map(object) {} No Per-table retention overrides keyed by table name.
solutions list(string) [] No OMS Gallery solutions to enable (e.g. SecurityInsights).
tags map(string) {} No Tags applied to the workspace and solutions.

Outputs

Name Description
id Resource ID of the workspace; use as log_analytics_workspace_id in diagnostic settings.
name Name of the workspace.
workspace_id Customer/workspace GUID (the portal “Workspace ID”) used by agents and APIs.
primary_shared_key Primary shared key for agent enrollment (sensitive).
secondary_shared_key Secondary shared key, useful for rotation (sensitive).
table_ids Map of table name to table resource ID for any per-table retention overrides.

Enterprise scenario

A financial-services platform team runs roughly 40 subscriptions under one management-group hierarchy and is required by their regulator to retain security and audit logs for 365 days while keeping noisy diagnostic data short to control spend. They deploy this module once per landing-zone subscription pinned to v1.0.0, setting daily_quota_gb = 25 as a budget guardrail, local_authentication_disabled = true with internet_ingestion_enabled = false for private-link-only access, and a table_retention map that holds SecurityEvent at 180 interactive / 365 total days while trimming ContainerLogV2 to 30 days on the Basic plan. Microsoft Sentinel is wired up automatically through the SecurityInsights solution, and every subscription’s workspace id output feeds an Azure Policy assignment that forces all resources to send diagnostics to the central workspace.

Best practices

TerraformAzureLog Analytics WorkspaceModuleIaC
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