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
- You are standing up a centralized logging workspace for a subscription or landing zone that other resources send diagnostics to.
- You want a hard daily ingestion cap so a misbehaving workload cannot blow the monitoring budget.
- You need per-table retention (e.g. keep
SecurityEvent180 days butContainerLogV2only 30) without hand-editing the portal. - You are deploying Microsoft Sentinel or Defender for Cloud, both of which require a workspace and benefit from a consistent SKU and retention baseline.
- You operate many subscriptions and want every workspace named, tagged, capped, and retained identically through a single module version.
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 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/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
- Always set
daily_quota_gbin production. An uncappedPerGB2018workspace has no spending ceiling; a single chatty app or misconfigured debug logger can multiply your monthly bill. Pair the cap with a budget alert and treat hitting it as a signal, not a silent loss. - Tune retention per table, not workspace-wide. Keep
SecurityEvent,SigninLogs, and audit tables long for compliance, but move verbose tables likeContainerLogV2to theBasicplan with short retention — Basic logs ingest far cheaper and you rarely query raw container chatter beyond a day or two. - Disable shared-key auth where you can. Set
local_authentication_disabled = trueand use Azure AD with table-level RBAC instead of distributing primary/secondary keys; if you must use keys, rotate the primary using the secondary key and store both only in Key Vault. - Lock the data plane down for sensitive workloads. Set
internet_ingestion_enabledandinternet_query_enabledtofalseand front the workspace with Azure Monitor Private Link Scope (AMPLS) so logs never traverse the public internet. - Name and tag for fleet operations. Use a consistent convention like
law-<purpose>-<env>-<region>(e.g.law-platform-prod-cin) and always setenvironment,owner, andcost_centertags so per-workspace ingestion cost is attributable across dozens of subscriptions. - Centralize, but mind data residency. Prefer one workspace per landing zone over one-per-resource to keep KQL queries and Sentinel correlation simple, while ensuring
locationsatisfies regional data-residency rules — cross-region ingestion also incurs egress and latency.