Designing Terraform Modules for Platform Teams: A Deep Dive
Designing Terraform Modules for Platform Teams: A Deep Dive
Terraform modules become reliable platform products only when their APIs are explicit. This guide covers interface design, composition roots, guardrails, tests, releases, docs, and operating models.
TL;DR
Platform teams should design Terraform modules as versioned product APIs, not shared folders of HCL. A good module exposes a small typed interface, validates unsafe input early, emits stable outputs, avoids embedded provider configuration, and leaves environment composition to root modules. Production readiness also requires examples, terraform test coverage, lint and policy guardrails, semantic versioning, registry documentation, a deprecation process, and ownership rules that define support, review, release cadence, consumer migration paths, and upgrade evidence for every supported module repository.
Terraform Modules Are Platform APIs
Terraform modules help platform teams standardize cloud infrastructure and reduce repeated code. The deeper problem is that many teams stop at "reuse" and never design the module as an API. They move HCL into modules/, expose every provider argument as a variable, and call the result a platform abstraction. Six months later, every service team has a slightly different combination of flags, security exceptions, provider versions, and undocumented outputs.
A platform module should behave more like a product contract:
- It hides implementation detail without hiding operational truth.
- It exposes only the choices consumers should own.
- It validates unsafe values before a provider API returns a vague error.
- It emits stable outputs that composition roots can wire into other modules.
- It ships examples, tests, generated docs, and a release policy.
- It has owners who manage breaking changes, support windows, and migration paths.
HashiCorp's module guidance is a useful baseline: modules are containers for resources that belong together, but overusing modules can make Terraform harder to understand. The strongest module is not the one with the most variables. It is the one that raises the abstraction level to a concept your platform actually supports: "private service bucket", "regional workload identity", "standard ECS service", "tenant network slice", or "managed database baseline".
That is the design lens for this deep dive.
Start With the Module Boundary
Before writing HCL, decide which team owns each decision. The module interface is a governance boundary.
| Decision | Usually owned by platform module | Usually owned by composition root |
|---|---|---|
| Encryption defaults | Yes | No |
| Required tags and labels | Yes | Environment can add more |
| Naming convention | Yes | Provides app/env/service identifiers |
| Network placement policy | Sometimes | Selects actual VPC/subnets |
| Provider region/account | No | Yes |
| IAM permission model | Yes for baseline, no for app permissions | Supplies app-specific statements |
| Observability defaults | Yes | Selects destinations or alert thresholds |
| Environment lifecycle | No | Yes |
This distinction keeps the reusable module focused. The module can enforce platform standards, while the root module decides how modules connect in a specific environment.
A good repository layout makes that boundary visible:
terraform-aws-platform-service/
README.md
LICENSE
versions.tf
variables.tf
main.tf
outputs.tf
locals.tf
tests/
service_contract.tftest.hcl
examples/
simple/
main.tf
multi-region/
main.tf
modules/
iam/
logging/
HashiCorp's standard module structure expects the root module to be the primary entrypoint and recommends README files, examples, and a layout that tooling can understand. Use that convention even for private modules because registry tooling, documentation generators, and reviewers all benefit from a predictable shape.
Opinionated Modules vs Primitive Modules
Platform teams need both building blocks and paved roads, but they should not confuse them.
A primitive module is close to a provider resource:
module "bucket" {
source = "app.terraform.io/acme/s3-bucket/aws"
bucket = "my-bucket"
acl = "private"
tags = var.tags
}
If the module mostly renames aws_s3_bucket arguments, it may not be worth owning. It adds another release stream, another README, and another set of compatibility promises without changing the consumer experience much.
An opinionated module encodes a platform standard:
module "service_artifacts" {
source = "app.terraform.io/acme/platform-artifact-bucket/aws"
version = "~> 2.4"
service = {
name = "checkout"
environment = "prod"
owner = "payments-platform"
}
retention = {
noncurrent_days = 30
expire_days = 365
}
}
Inside the module, the platform team owns the defaults:
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.kms_key_arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
The interface says, "Choose your service identity and lifecycle policy." It does not ask the service team whether public ACLs should be blocked. That is the platform standard.
Use this rule:
Create primitive modules only when they centralize a real cross-cutting concern. Create opinionated modules when they encode a supported internal platform capability.
Design Inputs as a Typed Contract
Module variables should be small, typed, and named from the consumer's domain. Avoid provider-shaped APIs such as bucket_acl, force_destroy, kms_key_deletion_window_in_days, and logging_target_bucket unless those are decisions consumers should truly own.
Prefer one or two structured objects over twenty loose variables when the fields belong together:
variable "service" {
description = "Service identity used for naming, ownership, tagging, and policy decisions."
type = object({
name = string
environment = string
owner = string
cost_center = optional(string)
})
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,30}$", var.service.name))
error_message = "service.name must be 3-31 characters and use lowercase letters, numbers, and hyphens."
}
validation {
condition = contains(["dev", "stage", "prod"], var.service.environment)
error_message = "service.environment must be one of dev, stage, or prod."
}
}
variable "retention" {
description = "Object retention policy for versioned artifacts."
type = object({
noncurrent_days = optional(number, 30)
expire_days = optional(number, 365)
})
default = {}
validation {
condition = var.retention.expire_days >= var.retention.noncurrent_days
error_message = "retention.expire_days must be greater than or equal to retention.noncurrent_days."
}
}
Terraform's optional object attributes let a module accept concise input while still applying defaults inside a precise type. That is better than making every field nullable and then handling null throughout the module.
Use optional attributes for defaults that are part of the module contract. Use required attributes for values the caller must understand. Use validation blocks when the provider would otherwise fail late or allow a value that violates platform policy.
Avoid any unless the module is passing a document through without understanding it. The more a platform module relies on any, the less it can protect its consumers.
Keep Locals Boring and Intentional
locals should translate consumer intent into provider arguments. They should not become a hidden policy engine that nobody can inspect.
locals {
name_prefix = "${var.service.environment}-${var.service.name}"
common_tags = merge(
{
ManagedBy = "terraform"
Service = var.service.name
Environment = var.service.environment
Owner = var.service.owner
},
var.service.cost_center == null ? {} : {
CostCenter = var.service.cost_center
},
var.tags
)
lifecycle_rules = [
{
id = "expire-noncurrent"
enabled = true
noncurrent_version_expiration = {
noncurrent_days = var.retention.noncurrent_days
}
},
{
id = "expire-current"
enabled = true
expiration = {
days = var.retention.expire_days
}
}
]
}
The naming and tags are not generic helper code. They are part of the platform contract. If you change tag keys, name shape, or lifecycle rule IDs, that can affect cost reporting, IAM conditions, dashboards, and policy checks. Treat those as release-worthy behavior.
Outputs Are Integration Points
Outputs are not just convenience values. They are how root modules compose infrastructure. Keep them stable, documented, and minimal.
output "bucket" {
description = "Stable identifiers for the artifact bucket."
value = {
id = aws_s3_bucket.this.id
arn = aws_s3_bucket.this.arn
name = aws_s3_bucket.this.bucket
}
}
output "writer_policy_arn" {
description = "IAM policy ARN that grants write access to the artifact bucket."
value = aws_iam_policy.writer.arn
}
Do not output entire provider resources. That leaks implementation details and tempts consumers to depend on fields you did not intend to support. Output small objects that represent stable integration contracts.
Mark sensitive values explicitly:
output "database_password" {
description = "Generated database password for bootstrap automation."
value = random_password.database.result
sensitive = true
}
HashiCorp's output documentation warns that sensitive outputs can still be stored in state and exposed through some CLI modes. For platform modules, the better design is usually to avoid producing secrets as outputs at all. Put secrets in a secret manager and output the secret identifier, not the secret value.
Provider Requirements Belong in Modules; Provider Configuration Belongs in Roots
Reusable modules should declare which providers they require:
terraform {
required_version = ">= 1.6.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 7.0"
}
}
}
They should not define provider blocks:
# Do not put this in a reusable child module.
provider "aws" {
region = "eu-west-1"
}
HashiCorp documents provider configurations as global to the Terraform configuration and defined only in the root module. A child module that embeds its own provider configuration becomes harder to use with for_each, count, and explicit provider wiring.
Provider aliases are the clean way to support cross-account or multi-region patterns. The child module declares aliases it expects:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
configuration_aliases = [
aws.primary,
aws.replica
]
}
}
}
resource "aws_s3_bucket" "primary" {
provider = aws.primary
bucket = local.primary_bucket_name
}
resource "aws_s3_bucket" "replica" {
provider = aws.replica
bucket = local.replica_bucket_name
}
The composition root supplies real provider instances:
provider "aws" {
alias = "primary"
region = "eu-west-1"
}
provider "aws" {
alias = "replica"
region = "eu-central-1"
}
module "artifact_bucket" {
source = "app.terraform.io/acme/platform-artifact-bucket/aws"
version = "~> 2.4"
providers = {
aws.primary = aws.primary
aws.replica = aws.replica
}
service = {
name = "checkout"
environment = "prod"
owner = "payments-platform"
}
}
That keeps account, region, and credential ownership in the root module where environment boundaries are visible.
Composition Roots Should Stay Flat
A common platform-team mistake is building one giant module that creates network, IAM, compute, database, observability, DNS, and deployment pipelines. It feels convenient until one consumer needs to bring an existing VPC, another needs a shared database subnet group, and a third needs a region-specific provider alias.
HashiCorp recommends relatively flat module trees and module composition: build smaller modules, then connect them in the root module by passing outputs from one module into inputs of another.
module "network" {
source = "app.terraform.io/acme/platform-network/aws"
version = "~> 3.2"
environment = "prod"
cidr_block = "10.40.0.0/16"
}
module "service_identity" {
source = "app.terraform.io/acme/workload-identity/aws"
version = "~> 1.8"
service = local.service
}
module "service" {
source = "app.terraform.io/acme/ecs-service/aws"
version = "~> 4.1"
service = local.service
network = {
vpc_id = module.network.vpc_id
private_subnet_ids = module.network.private_subnet_ids
security_group_ids = [module.network.workload_security_group_id]
}
task_role_arn = module.service_identity.task_role_arn
}
This is dependency inversion in Terraform form. The ecs-service module does not create its own VPC or IAM role unless that is the exact platform product it owns. It accepts the identifiers it needs. The root module decides whether those identifiers come from a platform network module, remote state, data sources, or a migration bridge.
Composition roots are also where environment policy belongs:
locals {
service = {
name = "checkout"
environment = "prod"
owner = "payments-platform"
}
deletion_protection = local.service.environment == "prod"
}
module "database" {
source = "app.terraform.io/acme/postgres-baseline/aws"
version = "~> 5.0"
service = local.service
subnet_ids = module.network.database_subnet_ids
deletion_protection = local.deletion_protection
}
The module can validate that production cannot disable protection, but the root knows it is production.
Guardrails Inside the Module
Use Terraform validation features at the earliest useful phase.
Input validation catches bad caller values:
variable "allowed_cidr_blocks" {
description = "CIDR blocks allowed to reach the service ingress."
type = set(string)
validation {
condition = alltrue([
for cidr in var.allowed_cidr_blocks :
can(cidrhost(cidr, 0)) && cidr != "0.0.0.0/0"
])
error_message = "allowed_cidr_blocks must contain valid CIDR blocks and must not include 0.0.0.0/0."
}
}
Preconditions document assumptions before a resource proceeds:
resource "aws_security_group_rule" "ingress" {
for_each = var.allowed_cidr_blocks
type = "ingress"
security_group_id = aws_security_group.service.id
protocol = "tcp"
from_port = 443
to_port = 443
cidr_blocks = [each.value]
lifecycle {
precondition {
condition = var.service.environment != "prod" || !contains(var.allowed_cidr_blocks, "0.0.0.0/0")
error_message = "Production services cannot expose HTTPS ingress to 0.0.0.0/0 through this module."
}
}
}
Postconditions protect guarantees:
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
lifecycle {
postcondition {
condition = self.block_public_policy && self.restrict_public_buckets
error_message = "The module must keep public bucket policies blocked."
}
}
}
Terraform check blocks are useful for non-blocking runtime assertions, especially when paired with continuous validation in HCP Terraform:
check "service_endpoint_health" {
data "http" "health" {
url = "https://${aws_route53_record.service.fqdn}/healthz"
}
assert {
condition = data.http.health.status_code == 200
error_message = "Service health endpoint did not return HTTP 200."
}
}
Module-level guardrails should be clear and local. Organization-wide rules such as "no public S3 buckets", "no wildcard admin IAM", or "all resources need cost tags" usually belong in policy checks against plans, not hidden in every module.
Guardrails Around the Module
Platform teams should publish a standard quality gate that every module repository uses:
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
tflint --init
tflint --recursive
terraform test
TFLint's Terraform language rules can catch missing variable and output descriptions, deprecated syntax, duplicate map keys, and other issues before a module reaches consumers. Provider-specific TFLint plugins can add AWS, Azure, or Google checks, but do not treat static lint as a substitute for plan policy.
A minimal plan-policy check can block obvious hazards:
#!/usr/bin/env bash
set -euo pipefail
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
jq -e '
[
.resource_changes[]?
| select(.type == "aws_iam_policy")
| select(.change.actions | index("create") or index("update"))
]
| length == 0
' tfplan.json >/dev/null || {
echo "IAM policy changes require platform security review."
exit 1
}
The first version can be blunt. Over time, replace shell scripts with a policy engine that understands approved exceptions, owners, and risk categories. The principle stays the same: modules should make safe behavior easy, and pipeline policy should catch unsafe combinations across modules.
Tests: Fast Contracts and Real Examples
Terraform's native test framework lets module authors write .tftest.hcl files. Since Terraform v1.6, tests can validate module behavior, and later versions added provider mocking capabilities for more unit-test-like coverage. Use this to protect the public contract.
A plan-only contract test can verify naming and guardrails without creating resources:
# tests/service_contract.tftest.hcl
variables {
service = {
name = "checkout"
environment = "prod"
owner = "payments-platform"
}
}
run "plan_contract" {
command = plan
assert {
condition = startswith(aws_s3_bucket.this.bucket, "prod-checkout-")
error_message = "Bucket name must start with the environment and service name."
}
assert {
condition = aws_s3_bucket_public_access_block.this.block_public_policy == true
error_message = "Public bucket policies must be blocked."
}
}
An apply test should use disposable resources and tight cleanup expectations:
run "apply_example" {
command = apply
variables {
service = {
name = "tfmodtest"
environment = "dev"
owner = "platform-ci"
}
}
assert {
condition = output.bucket.arn != ""
error_message = "The module must return a bucket ARN."
}
}
Do not rely on tests alone. Keep executable examples in examples/ and run at least one in CI on a schedule or before release:
# examples/simple/main.tf
module "artifact_bucket" {
source = "../.."
service = {
name = "example"
environment = "dev"
owner = "platform-team"
}
}
output "bucket" {
value = module.artifact_bucket.bucket
}
Examples serve three audiences at once: humans reading the registry page, CI validating real usage, and consumers copying a known-good starting point.
Versioning: SemVer for Infrastructure Contracts
Terraform Registry module versions are based on Git tags. For public registry modules, tags must be semantic versions such as 1.2.3 or v1.2.3. Private registries commonly follow the same convention because it is easy for consumers to reason about.
Use SemVer as a consumer-impact language:
| Change | Release type | Why |
|---|---|---|
| Fix README typo | Patch | No runtime behavior change |
| Fix tag merge bug without changing interface | Patch | Compatible defect fix |
| Add optional input with safe default | Minor | Backwards-compatible feature |
| Add output without changing existing outputs | Minor | Backwards-compatible feature |
| Change default encryption key behavior | Major or minor with opt-in first | Can alter managed resources |
| Rename input variable | Major | Breaking interface change |
| Remove output | Major | Breaks downstream composition |
Move resources without moved blocks | Major and migration guide | Can force replacement or state surgery |
The hard part is that infrastructure changes can be breaking even when the HCL interface is unchanged. Changing a default from enable_access_logs = false to true may create resources, alter costs, or require permissions. Changing a naming algorithm may replace resources. Changing tag values may affect IAM conditions, budget allocation, or backup selection.
Document a release policy in the module README:
## Versioning policy
- Patch: compatible bug fixes and documentation corrections.
- Minor: backwards-compatible inputs, outputs, examples, and optional capabilities.
- Major: removed or renamed inputs, removed outputs, behavior changes that can replace resources, and state-address changes without automatic migration.
- Deprecations: supported for at least two minor releases before removal.
Pin modules from root configurations:
module "artifact_bucket" {
source = "app.terraform.io/acme/platform-artifact-bucket/aws"
version = "~> 2.4"
service = local.service
}
Avoid unbounded module usage. Terraform's dependency lock file tracks provider selections, not remote module version selections. Exact or compatible module constraints make root behavior reviewable and repeatable.
Semantic Release for Terraform Modules
semantic-release can publish non-JavaScript artifacts because its core behavior is commit analysis, changelog generation, tagging, and release publishing. For Terraform modules, the useful output is a SemVer Git tag and release notes, not an npm package.
A lightweight setup can use conventional commits:
{
"branches": ["main"],
"tagFormat": "v${version}",
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/github",
{
"successComment": false,
"failComment": false
}
]
]
}
The release workflow should run after tests pass:
name: release
on:
push:
branches: ["main"]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.14.6"
- run: terraform fmt -check -recursive
- run: terraform init -backend=false
- run: terraform validate
- run: terraform test
- uses: actions/setup-node@v4
with:
node-version: "lts/*"
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Define commit semantics for infrastructure:
fix: preserve bucket tags when custom tags are omitted
feat: add optional replication configuration
feat!: rename service.owner to service.team
docs: add multi-region example
test: cover production deletion protection
Do not let automation decide breaking-change meaning without review. A module owner should label or enforce conventional commits and confirm that feat: is used for resource replacement risks, removed inputs, removed outputs, and state-address changes.
Deprecation Strategy
Deprecation is where platform teams prove whether the module is a product.
A good deprecation process has four parts:
- Announce the replacement in release notes and README.
- Keep the old input or behavior for a defined window.
- Add validation warnings where Terraform can express them, or CI warnings where it cannot.
- Remove the old contract only in a major release.
Terraform variable validation cannot emit a warning; it either passes or fails. For soft deprecation, keep the variable and translate it internally:
variable "team" {
description = "Owning team. Prefer service.owner for new consumers."
type = string
default = null
}
variable "service" {
description = "Service identity."
type = object({
name = string
environment = string
owner = optional(string)
})
}
locals {
owner = coalesce(var.service.owner, var.team)
}
resource "null_resource" "deprecated_team_input" {
count = var.team == null ? 0 : 1
lifecycle {
precondition {
condition = false
error_message = "The team input is deprecated. Set service.owner instead."
}
}
}
That example is intentionally hard-failing, which is appropriate only after the migration window. During the soft window, prefer README notices, release notes, CI checks in consumer repositories, or a non-blocking policy report. Terraform does not have a first-class warning mechanism for variables.
For state moves, use moved blocks when possible:
moved {
from = aws_s3_bucket.main
to = aws_s3_bucket.this
}
For changes that cannot be represented safely, publish a migration guide:
## Migrating from v2 to v3
1. Upgrade to v2.9.0 and apply. This release includes moved blocks.
2. Replace `team` with `service.owner`.
3. Confirm no consumers read `module.artifact_bucket.bucket_name`.
4. Upgrade to v3.0.0.
5. Run `terraform plan` and confirm no bucket replacement is proposed.
Breaking changes are acceptable. Surprise breaking changes are not.
Registry Documentation
Terraform Registry extracts module metadata from repository structure, README content, variables, outputs, dependencies, examples, and tags. That means documentation should be treated as part of the release artifact.
Minimum README sections:
# terraform-aws-platform-artifact-bucket
Creates a private, encrypted, versioned S3 bucket for service artifacts.
## When to use this module
Use this module when a service needs durable internal artifact storage
with platform-managed encryption, ownership tags, lifecycle policy,
and public access blocking.
## When not to use this module
Do not use this module for public website hosting, data lake buckets,
or buckets managed by a third-party product.
## Examples
- examples/simple
- examples/replicated
## Versioning and support
Supported major versions: v2 and v3.
Deprecation window: two minor releases or 90 days, whichever is longer.
Generate inputs and outputs automatically with a tool such as terraform-docs, but do not outsource the whole README to generated tables. The most valuable documentation explains intent, tradeoffs, examples, migration notes, and operational ownership.
For platform teams, include a support matrix:
| Capability | Supported |
| --- | --- |
| AWS partitions | commercial only |
| Regions | eu-west-1, eu-central-1 |
| Terraform CLI | >= 1.6, < 2.0 |
| AWS provider | >= 5.0, < 7.0 |
| Production use | yes |
| Public bucket mode | no |
The registry page should let a service team answer, "Should I use this?" before they copy the first block.
Platform Team Operating Model
The HCL is only half the module. The operating model decides whether consumers trust it.
Define ownership:
module: terraform-aws-platform-artifact-bucket
owners:
- platform-infra
support:
slack: "#platform-terraform"
response_slo: "2 business days"
release:
cadence: "weekly or on demand for security fixes"
supported_major_versions: ["2", "3"]
review:
required:
- platform-infra
- security-for-iam-or-public-networking
Define intake rules:
- New modules require a design review and at least one real consumer.
- New inputs need a consumer-owned decision, not just "someone might need this."
- New defaults need a risk review because defaults are behavior.
- New examples need CI coverage if they represent a supported pattern.
- New major versions need a migration guide and named early adopters.
Track adoption with a simple inventory:
module,version,root,owner,environment,last_plan
platform-artifact-bucket,2.4.1,infra/live/prod/checkout,payments-platform,prod,2026-06-13
platform-artifact-bucket,2.3.0,infra/live/stage/billing,billing-platform,stage,2026-06-12
You can build this inventory from HCP Terraform, private registry APIs, repository search, or CI plan metadata. Without it, deprecation becomes guesswork.
The platform team's job is not to approve every Terraform line forever. It is to create a small set of well-supported APIs that let application teams move safely without repeatedly rediscovering security, tagging, networking, and release discipline.
A Complete Interface Example
This example pulls the design together.
# versions.tf
terraform {
required_version = ">= 1.6.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 7.0"
}
}
}
# variables.tf
variable "service" {
description = "Service identity used for names, tags, and ownership."
type = object({
name = string
environment = string
owner = string
cost_center = optional(string)
})
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,30}$", var.service.name))
error_message = "service.name must be lowercase kebab-case and 3-31 characters long."
}
}
variable "tags" {
description = "Additional tags merged with platform-required tags."
type = map(string)
default = {}
}
variable "kms_key_arn" {
description = "KMS key ARN used for bucket encryption."
type = string
validation {
condition = can(regex("^arn:aws:kms:", var.kms_key_arn))
error_message = "kms_key_arn must be an AWS KMS key ARN."
}
}
# main.tf
locals {
bucket_name = "${var.service.environment}-${var.service.name}-artifacts"
tags = merge(
{
ManagedBy = "terraform"
Module = "platform-artifact-bucket"
Service = var.service.name
Environment = var.service.environment
Owner = var.service.owner
},
var.service.cost_center == null ? {} : {
CostCenter = var.service.cost_center
},
var.tags
)
}
resource "aws_s3_bucket" "this" {
bucket = local.bucket_name
tags = local.tags
lifecycle {
precondition {
condition = var.service.environment != "prod" || var.kms_key_arn != ""
error_message = "Production artifact buckets must use an explicit KMS key."
}
}
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.kms_key_arn
}
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# outputs.tf
output "bucket" {
description = "Stable artifact bucket identifiers."
value = {
id = aws_s3_bucket.this.id
arn = aws_s3_bucket.this.arn
name = aws_s3_bucket.this.bucket
}
}
This module is intentionally narrower than a generic S3 module. It is a platform artifact bucket. It owns encryption, versioning, tags, and public access blocking. It does not try to support every possible S3 feature.
Frequently Asked Questions
Q: How should platform teams design Terraform modules?
A: Design Terraform modules as product APIs. Start with the consumer decisions the platform wants to expose, encode the rest as secure defaults, validate unsafe input early, publish stable outputs, and support the module with examples, tests, semantic versions, registry docs, and a deprecation process.
Q: What is the difference between opinionated and primitive Terraform modules?
A: An opinionated module represents a supported platform capability such as a compliant service bucket or ECS service baseline. A primitive module mostly wraps a provider resource. Primitive modules are useful only when they centralize real cross-cutting behavior; otherwise they add indirection without improving safety.
Q: How do Terraform provider aliases work inside modules?
A: Child modules declare provider requirements and any configuration_aliases they expect. Root modules define provider blocks for accounts, regions, or credentials, then pass those provider instances into child modules with the providers argument.
Q: How should Terraform modules be versioned?
A: Use semantic versioning and immutable Git tags. Treat input renames, output removals, resource address changes, replacement-prone default changes, and behavior changes as breaking unless you provide an automatic migration path and a documented compatibility window.
Q: How do you test Terraform modules before publishing?
A: Run terraform fmt, terraform validate, TFLint, and terraform test. Add plan tests for interface contracts, apply tests for disposable examples, and policy checks for risky plan changes such as public exposure, destructive deletes, broad IAM permissions, and missing tags.
Related Internal Guides
- Modern Terraform Reference Architecture on Amazon EKS
- Terraform Security Best Practices for AWS IAM
- Drift Detection and Remediation
- Using Terraform GitOps Bridge Modules
Resources
- Original source post: Designing Terraform Modules for Platform Teams
- HashiCorp: Creating modules
- HashiCorp: Standard module structure
- HashiCorp: Module composition
- HashiCorp: Providers within modules
- HashiCorp: Type constraints and optional object attributes
- HashiCorp: Input variables
- HashiCorp: Output values
- HashiCorp: Validate your configuration
- HashiCorp: Terraform tests
- HashiCorp: Dependency lock file
- HashiCorp: Publish modules to the Terraform Registry
- SemVer: Semantic Versioning 2.0.0
- semantic-release: semantic-release documentation
- TFLint: TFLint project
- TFLint: Terraform language rules
Comments
Post a Comment