All skills
thomast1906 avatar

/terraform-module-creator

@b198c92

Design and create Terraform modules from real infrastructure requirements. Use when asked to create a Terraform module, build a module from a requirement, turn repeated Terraform into a module, design module inputs and outputs, review whether a Terraform pattern should become a module, refactor existing Terraform into a reusable module, or assess whether a module is over-abstracted. Covers Azure-focused modules with KISS/DRY principles, module boundary definition, interface design, and practical file structure generation. Do NOT use for general Terraform coding with no module boundary, provider version upgrades (use terraform-provider-upgrade skill), or non-Terraform IaC.

Use this Skill: https://skilld.dev/gh/thomast1906/github-copilot-agent-skills/terraform-module-creator

This session only. Nothing lands on disk.

referencesMODULE-PATTERNS.md

≈4.6k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Module Patterns Reference — Terraform Module Creator

Common patterns for Azure Terraform modules. Use these as starting points, not templates to copy blindly. Adapt to the actual requirement.


Standard Module File Structure

module/
├── main.tf              # Primary resource declarations
├── variables.tf         # Input variable definitions
├── outputs.tf           # Output value definitions
├── versions.tf          # Terraform and provider version constraints
├── locals.tf            # Local value computations (add only if needed)
├── .terraform-docs.yml  # terraform-docs configuration
├── .tflint.hcl          # tflint static analysis configuration
└── README.md            # Purpose, usage example, generated inputs/outputs table

Only add locals.tf when it improves readability. Do not add files for the sake of neatness.

The inputs/outputs table in README.md is generated by terraform-docs, not written by hand. Always include .terraform-docs.yml and the marker comments in README.md.


terraform-docs Configuration

Always generate a .terraform-docs.yml file in the module root.

# .terraform-docs.yml
formatter: "markdown table"

version: ""

header-from: main.tf
footer-from: ""

recursive:
  enabled: false

sections:
  hide: []
  show: []

content: |-
  {{ .Header }}

  ## Usage

  See the [Usage Example](#usage-example) section below.

  {{ .Requirements }}

  {{ .Providers }}

  {{ .Modules }}

  {{ .Resources }}

  {{ .Inputs }}

  {{ .Outputs }}

output:
  file: README.md
  mode: inject
  template: |-
    <!-- BEGIN_TF_DOCS -->
    {{ .Content }}
    <!-- END_TF_DOCS -->

sort:
  enabled: true
  by: required

Key settings:

  • mode: inject — terraform-docs injects content between the marker comments in README.md, preserving everything outside the markers
  • sort.by: required — required variables appear first, optional (with defaults) after
  • formatter: "markdown table" — renders inputs and outputs as clean markdown tables

Run after any variable or output changes:

terraform-docs .

Install terraform-docs:

# macOS
brew install terraform-docs

# Linux
curl -Lo ./terraform-docs.tar.gz https://github.com/terraform-docs/terraform-docs/releases/latest/download/terraform-docs-linux-amd64.tar.gz
tar -xzf terraform-docs.tar.gz && chmod +x terraform-docs && mv terraform-docs /usr/local/bin/

# Via go
go install github.com/terraform-docs/terraform-docs@latest

README.md Template

Generate a README with this structure. The <!-- BEGIN_TF_DOCS --> / <!-- END_TF_DOCS --> block is populated by terraform-docs . — everything outside it is maintained by hand.

# terraform-<resource-type>

> One-sentence description of what this module does and why it exists.

## Purpose

Brief explanation of the module's responsibility and the problem it solves.

## Module boundary

- **Owns:** list what the module creates
- **Expects from consumers:** list what must be provided (VNet IDs, DNS zone IDs, workspace IDs)
- **Does not own:** list what remains outside the module

## Usage example

```hcl
module "example" {
  source = "git::https://github.com/your-org/terraform-modules.git//modules/<name>?ref=v1.0.0"

  name                = "<name>"
  resource_group_name = "<rg>"
  location            = "uksouth"

  tags = {
    environment = "prod"
    project     = "myapp"
    owner       = "platform-team"
    cost-center = "CC-12345"
  }
}

Assumptions and trade-offs

  • list any important design decisions or assumptions
  • note what has been left out deliberately
<!-- BEGIN_TF_DOCS --> <!-- END_TF_DOCS -->

**Important:** Do not manually edit the content between the markers. Run `terraform-docs .` after any change to variables or outputs.

---

## pre-commit Hook (Optional)

If the team uses pre-commit, add this hook to `.pre-commit-config.yaml` in the repository root to auto-regenerate the README on every commit:

```yaml
repos:
  - repo: https://github.com/terraform-docs/terraform-docs
    rev: "v0.19.0"   # pin to latest stable
    hooks:
      - id: terraform-docs-go
        args: ["--config", ".terraform-docs.yml", "."]

This ensures the README is always in sync with the module code without a manual step.


versions.tf Pattern

Always pin to a specific minimum version. Use mcp_terraform_get_latest_provider_version to get the current latest before generating.

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 4.0.0"
    }
  }
}

Guidance:

  • Use >= with a minimum — not an exact pin — to allow patch upgrades in the consumer
  • Always include required_version for Terraform itself
  • Add azuread or random only if the module genuinely needs them

variables.tf Pattern

# --- Required variables (no default) ---
variable "name" {
  description = "Name of the resource. Must be globally unique."
  type        = string
}

variable "resource_group_name" {
  description = "Name of the resource group to deploy into."
  type        = string
}

variable "location" {
  description = "Azure region for the resource (e.g. 'uksouth', 'westeurope')."
  type        = string
}

# --- Optional variables (with safe defaults) ---
variable "sku" {
  description = "SKU tier for the resource. Defaults to Standard."
  type        = string
  default     = "Standard"

  validation {
    condition     = contains(["Basic", "Standard", "Premium"], var.sku)
    error_message = "sku must be one of: Basic, Standard, Premium."
  }
}

variable "tags" {
  description = "Map of tags to apply to all resources created by this module."
  type        = map(string)
  default     = {}
}

variable "diagnostic_settings" {
  description = "Optional diagnostic settings to forward logs and metrics to a Log Analytics workspace."
  type = object({
    enabled                    = bool
    log_analytics_workspace_id = optional(string)
    retention_days             = optional(number, 30)
  })
  default = {
    enabled = false
  }
}

Guidance:

  • Group required variables first, optional variables after
  • Every variable needs a description
  • Use validation blocks for enum-style variables — avoid magic values
  • Use optional() inside object types for partial configuration
  • Avoid large object() variables that expose every possible resource argument

outputs.tf Pattern

output "id" {
  description = "Resource ID of the deployed resource."
  value       = azurerm_example.this.id
}

output "name" {
  description = "Name of the deployed resource."
  value       = azurerm_example.this.name
}

output "principal_id" {
  description = "Principal ID of the system-assigned managed identity."
  value       = azurerm_example.this.identity[0].principal_id
}

output "primary_connection_string" {
  description = "Primary connection string. Sensitive — do not log or expose in plan output."
  value       = azurerm_example.this.primary_connection_string
  sensitive   = true
}

Guidance:

  • Always output id — consumers almost always need it
  • Output name — useful for reference
  • Output principal_id if the resource has a managed identity
  • Do not output every attribute — only those consumers realistically need
  • Mark sensitive = true on any output that contains a secret: connection strings, access keys, passwords, SAS tokens
  • Prefer outputting the managed identity principal_id over a connection string — guide consumers toward keyless authentication

Variable Validation Blocks

Add validation blocks to catch invalid inputs at plan time, before any resource is touched. This is cleaner than buried conditionals or cryptic provider errors.

variable "account_tier" {
  description = "Storage account tier. Standard for general use, Premium for high IOPS."
  type        = string
  default     = "Standard"

  validation {
    condition     = contains(["Standard", "Premium"], var.account_tier)
    error_message = "account_tier must be 'Standard' or 'Premium'."
  }
}

variable "name" {
  description = "Name of the resource. Must be 3–24 lowercase alphanumeric characters."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9]{3,24}$", var.name))
    error_message = "name must be 3–24 lowercase alphanumeric characters (no hyphens)."
  }
}

variable "retention_days" {
  description = "Number of days to retain soft-deleted blobs. Must be between 1 and 365."
  type        = number
  default     = 7

  validation {
    condition     = var.retention_days >= 1 && var.retention_days <= 365
    error_message = "retention_days must be between 1 and 365."
  }
}

Guidance:

  • Add validation to enum-style variables (SKUs, tiers, kinds) — never rely on the provider to surface the error clearly
  • Add validation to name variables that have format constraints (storage account names, Key Vault names)
  • Keep error messages short and actionable — state the constraint, not just that it failed
  • Use can() with regex() for pattern validation — avoids throwing on non-matching strings

Lifecycle Preconditions and Postconditions

Use lifecycle preconditions to enforce cross-variable constraints that cannot be expressed in a single validation block. Available in Terraform >= 1.2.

Precondition — checked before the resource is created or updated:

resource "azurerm_private_endpoint" "this" {
  count = var.private_endpoint.enabled ? 1 : 0
  # ...

  lifecycle {
    precondition {
      condition     = var.private_endpoint.subnet_id != null
      error_message = "private_endpoint.subnet_id must be provided when private_endpoint.enabled is true."
    }
    precondition {
      condition     = var.private_endpoint.private_dns_zone_id != null
      error_message = "private_endpoint.private_dns_zone_id must be provided when private_endpoint.enabled is true."
    }
  }
}

Postcondition — checked after the resource is created, validates the real state:

resource "azurerm_storage_account" "this" {
  # ...

  lifecycle {
    postcondition {
      condition     = self.min_tls_version == "TLS1_2"
      error_message = "Storage account must enforce TLS 1.2. Check the provider version or resource arguments."
    }
  }
}

Guidance:

  • Use preconditions for cross-variable dependencies that cannot be expressed in a single validation block (e.g., "if X is enabled, Y must be set")
  • Use postconditions to assert that deployed resource state meets expectations — useful for compliance-critical defaults
  • Keep conditions simple — complex conditions belong in locals
  • Prefer preconditions over postconditions where possible (fail fast, before resources are created)

locals.tf Pattern

Use locals to construct names, derive values, or improve readability. Do not use locals to hide important logic.

locals {
  # Construct a canonical name if the module applies naming conventions
  resource_name = lower("${var.prefix}-${var.workload}-${var.environment}-${var.location}-${var.instance}")

  # Merge caller tags with module-level defaults
  tags = merge(
    {
      managed-by  = "terraform"
      module      = "storage-account"
      environment = var.environment
    },
    var.tags
  )

  # Derive a boolean from a nullable optional
  enable_diagnostics = var.diagnostic_settings != null && var.diagnostic_settings.enabled
}

Managed Identity Pattern

Always use managed identity over connection strings or keys where the resource supports it.

resource "azurerm_storage_account" "this" {
  # ...

  identity {
    type = "SystemAssigned"
  }
}

output "principal_id" {
  description = "Principal ID of the system-assigned managed identity for RBAC assignments."
  value       = azurerm_storage_account.this.identity[0].principal_id
}

For modules that need to configure RBAC, accept role_assignments as a variable:

variable "role_assignments" {
  description = "List of RBAC role assignments to create on the resource."
  type = list(object({
    principal_id         = string
    role_definition_name = string
  }))
  default = []
}

resource "azurerm_role_assignment" "this" {
  for_each = { for ra in var.role_assignments : ra.principal_id => ra }

  scope                = azurerm_storage_account.this.id
  role_definition_name = each.value.role_definition_name
  principal_id         = each.value.principal_id
}

Diagnostic Settings Pattern

Standard pattern for forwarding logs and metrics to a Log Analytics Workspace.

resource "azurerm_monitor_diagnostic_setting" "this" {
  count = var.diagnostic_settings.enabled ? 1 : 0

  name                       = "${azurerm_storage_account.this.name}-diagnostics"
  target_resource_id         = azurerm_storage_account.this.id
  log_analytics_workspace_id = var.diagnostic_settings.log_analytics_workspace_id

  enabled_log {
    category_group = "allLogs"
  }

  metric {
    category = "AllMetrics"
    enabled  = true
  }
}

Guidance:

  • Make diagnostic settings optional (default off, enabled on request)
  • Accept log_analytics_workspace_id as a variable — do not create a workspace inside the module
  • Use category_group = "allLogs" as the default — simpler than per-category configuration

Private Endpoint Pattern

Pass private endpoint configuration as an optional structured variable.

variable "private_endpoint" {
  description = "Optional private endpoint configuration for the resource."
  type = object({
    enabled             = bool
    subnet_id           = optional(string)
    private_dns_zone_id = optional(string)
  })
  default = {
    enabled = false
  }
}

resource "azurerm_private_endpoint" "this" {
  count = var.private_endpoint.enabled ? 1 : 0

  name                = "${azurerm_storage_account.this.name}-pe"
  location            = azurerm_storage_account.this.location
  resource_group_name = azurerm_storage_account.this.resource_group_name
  subnet_id           = var.private_endpoint.subnet_id

  private_service_connection {
    name                           = "${azurerm_storage_account.this.name}-psc"
    private_connection_resource_id = azurerm_storage_account.this.id
    subresource_names              = ["blob"]
    is_manual_connection           = false
  }

  dynamic "private_dns_zone_group" {
    for_each = var.private_endpoint.private_dns_zone_id != null ? [1] : []

    content {
      name                 = "default"
      private_dns_zone_ids = [var.private_endpoint.private_dns_zone_id]
    }
  }
}

Guidance:

  • Do not create the DNS zone inside the module — accept it as an ID
  • Do not create the subnet inside the module — accept it as an ID
  • Private endpoint is optional — default to disabled

Tagging Pattern

Always merge caller tags with module-level defaults.

variable "tags" {
  description = "Additional tags to apply to all resources. Module-level defaults are merged with these."
  type        = map(string)
  default     = {}
}

locals {
  tags = merge(
    {
      managed-by = "terraform"
    },
    var.tags
  )
}

Apply local.tags to every resource in the module.


Example Usage Block (for README)

Every module README should include an example that can be copied and run.

module "storage" {
  source = "git::https://github.com/your-org/terraform-modules.git//modules/storage-account?ref=v1.0.0"

  name                = "stmyappproduksouth001"
  resource_group_name = "rg-myapp-prod-uksouth-001"
  location            = "uksouth"

  sku = "Standard"

  tags = {
    environment = "prod"
    project     = "myapp"
    owner       = "platform-team"
    cost-center = "CC-12345"
  }

  diagnostic_settings = {
    enabled                    = true
    log_analytics_workspace_id = "/subscriptions/.../workspaces/law-myapp-prod"
    retention_days             = 30
  }

  private_endpoint = {
    enabled             = true
    subnet_id           = "/subscriptions/.../subnets/snet-pe-prod"
    private_dns_zone_id = "/subscriptions/.../privateDnsZones/privatelink.blob.core.windows.net"
  }
}

moved Blocks

Use moved blocks when refactoring module internals to rename or restructure resources without breaking consumer state. Without a moved block, Terraform plans to destroy the old resource and create a new one — which is destructive.

Rename a resource within the same module:

moved {
  from = azurerm_storage_account.storage
  to   = azurerm_storage_account.this
}

Rename a module call (at the consumer level):

moved {
  from = module.old_storage
  to   = module.storage
}

Move a resource into a for_each structure:

moved {
  from = azurerm_storage_account.this
  to   = azurerm_storage_account.this["primary"]
}

Guidance:

  • Always add a moved block when renaming a resource identifier in a module that has existing consumers
  • Place moved blocks in main.tf or a dedicated moved.tf — do not spread them across files
  • moved blocks accumulate over time and can be removed after all consumers have applied the change (typically after 1–2 release cycles)
  • If the resource type changes (not just the identifier), a moved block cannot help — that is a breaking change requiring a MAJOR version bump
  • Document moved blocks in the CHANGELOG under the version that introduces them

Anti-Patterns to Avoid

Anti-pattern Problem Better approach
Wrapping a single resource with no added value Just adds complexity Use the resource directly
Exposing all provider arguments as variables Creates a maintenance burden and confusing interface Expose only what consumers need
Creating DNS zones or VNets inside the module Tight coupling, unusable in existing environments Accept IDs as variables
Feature flag variables (enable_feature_x = true/false) Modules become implicit frameworks Separate modules for distinct patterns
count-based conditional resources with complex dependencies Creates hard-to-debug plan outputs Use for_each with a map or null checks
Hardcoded names Prevents reuse across environments Use variables for all names
Nested modules for a simple pattern Over-abstraction Flat structure is easier to read and maintain

Source: SKILL.md on GitHub

1 warning14d3 checks · Risk SAFE
  • Gen Agent Trust Hub14d

    The skill is designed to create Terraform modules and uses external tools and data sources. It carries a low risk of indirect prompt injection because it processes content from external registries and documentation to generate code. It also references common installation methods for third-party tools that involve piping remote scripts to a shell in its documentation.

  • Socket14d

    No alerts

  • Snyk14d

    Risk: MEDIUM · 1 issue

Signed by skilld at b198c92. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 5 months ago
Other metadata
metadata
{
  "author": "Thomas Thornton",
  "version": "1.0.0",
  "last-updated": "2026-05-19"
}

README badge

README badge for thomast1906/github-copilot-agent-skills/terraform-module-creator