When self-service creates chaos
Every platform team has faced this moment: a developer spins up a production database without backups, another creates a security group that exposes the entire cluster, and someone else accidentally deletes a load balancer during a refactor. The promise of developer self-service infrastructure collides with the reality of uncontrolled sprawl and compliance violations. Terraform for platform teams: what developers should know is about striking the balance—giving developers the speed and autonomy they need while keeping the infrastructure safe, predictable, and maintainable.
The answer isn't to lock everything down behind approval workflows that take days. It's to build golden modules, enforce sensible defaults, and create guardrails that make the safe path the easy path. When done right, developers get self-service infrastructure that Just Works, and your platform team sleeps better at night.
Golden modules — Approved modules for databases, queues and services
A golden module is an opinionated, well-tested Terraform module that encapsulates your organization's best practices for a common piece of infrastructure. Instead of asking developers to understand VPC configuration, security groups, monitoring, backups, and encryption individually, they use a module that handles all of it.
The power of golden modules lies in consolidation. You might have five different RDS configurations in production right now—some with backups enabled, some without; some with encryption at rest, some not; some with the wrong parameter groups. A golden module for RDS standardizes all of that. It becomes the single source of truth.
Here's what a simple golden module looks like in practice:
# modules/database/postgres/main.tf
resource "aws_db_instance" "primary" {
identifier = var.database_name
engine = "postgres"
engine_version = var.postgres_version
instance_class = var.instance_class
allocated_storage = var.allocated_storage
storage_encrypted = true
kms_key_id = aws_kms_key.db.arn
backup_retention_period = var.backup_retention_days
backup_window = "03:00-04:00"
copy_tags_to_snapshot = true
db_subnet_group_name = aws_db_subnet_group.default.name
publicly_accessible = false
vpc_security_group_ids = [aws_security_group.database.id]
iam_database_authentication_enabled = true
skip_final_snapshot = false
final_snapshot_identifier = "${var.database_name}-final-snapshot-${formatdate("YYYY-MM-DD-hhmm", timestamp())}"
enabled_cloudwatch_logs_exports = ["postgresql"]
tags = merge(var.common_tags, {
Name = var.database_name
ManagedBy = "terraform"
})
}
resource "aws_security_group" "database" {
name = "${var.database_name}-sg"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = var.application_security_group_ids
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = var.common_tags
}A developer using this module doesn't need to worry about encryption keys, backup windows, or network configuration. They specify what they need:
# my-service/main.tf
module "database" {
source = "git::https://github.com/myorg/terraform-modules//database/postgres?ref=v2.3.0"
database_name = "myservice_db"
instance_class = "db.t3.medium"
allocated_storage = 100
postgres_version = "15.4"
backup_retention_days = 30
application_security_group_ids = [aws_security_group.app.id]
vpc_id = var.vpc_id
common_tags = var.common_tags
}The module enforces encryption, backups, monitoring, and network isolation automatically. No decisions to make, no corners to cut. This is the foundation of safe self-service.
Simple interfaces — A few inputs, safe defaults
The difference between a usable module and an unusable one is input complexity. A module with 50 variables and unclear naming is a module nobody will use—or worse, they'll work around it by writing their own, defeating the entire purpose.
The best golden modules have as few required inputs as possible and sensible defaults for everything else. Developers should only expose what actually varies in your organization.
Look at the RDS module again: the required variables are just the database name, instance class, storage size, and Postgres version. Everything else—encryption, backups, logging, security—has a safe default. A developer can create a database with five lines of code and trust that it's production-ready.
Compare that to a poorly designed module:
# Bad: too many levers
module "database" {
source = "./modules/rds"
database_name = "mydb"
instance_class = "db.t3.medium"
allocated_storage = 100
engine_version = "15.4"
backup_retention_period = 30
storage_encrypted = true
kms_key_id = aws_kms_key.db.arn
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.default.name
publicly_accessible = false
copy_tags_to_snapshot = true
multi_az = false
enabled_cloudwatch_logs_exports = ["postgresql"]
iam_database_authentication_enabled = true
deletion_protection = true
# ... 20 more options
}This forces developers to make architectural decisions they shouldn't have to make. The "Bad" example works, but it's a maintenance burden and it's easy to make mistakes.
Keep module interfaces simple by:
- Making safe values the defaults. Encryption, backups, and monitoring should be on by default. Developers can only disable them with explicit effort (and maybe a comment explaining why).
- Using logical grouping. If three networking options always go together, create an abstraction for them. Let developers choose "isolated" or "public-facing" rather than fiddling with individual security group rules.
- Validating inputs early. Use Terraform's
validationblock to catch mistakes immediately, before infrastructure is created. - Documenting thoroughly. Golden modules should have a clear README with examples, not just parameter descriptions.
A good litmus test: can a developer who's never used your infrastructure create a production resource in under five minutes? If not, the interface is too complex.
Self-service pipelines — Developers request resources via pull requests
Golden modules are only half the story. Developers still need a mechanism to request and provision infrastructure. The best teams use pull request-driven workflows, where infrastructure requests flow through Git just like code.
The process looks like this:
- Developer creates a pull request in the infrastructure repository with a new Terraform configuration for their service.
- Automated checks run:
terraform fmt,terraform validate,tflint, policy checks (we'll cover that next). - Humans review the PR—typically a platform engineer.
- On merge, a CI/CD pipeline runs
terraform plan, shows the output in a comment, and waits for approval. - An authorized platform engineer approves, and the pipeline runs
terraform apply.
Here's a simplified GitHub Actions workflow:
name: Terraform Plan and Apply
on:
pull_request:
paths:
- 'infrastructure/**'
push:
branches: [main]
paths:
- 'infrastructure/**'
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.6.0
- name: Terraform Format
run: terraform fmt -check infrastructure/
- name: Terraform Init
run: cd infrastructure && terraform init
- name: Terraform Validate
run: cd infrastructure && terraform validate
- name: Terraform Plan
run: cd infrastructure && terraform plan -out=tfplan
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Comment Plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const plan = require('fs').readFileSync('infrastructure/tfplan', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `\`\`\`\n${plan}\n\`\`\``
});
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: cd infrastructure && terraform apply tfplan
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}This workflow keeps everything auditable. Every change is reviewed, every plan is visible, and every apply is logged. Developers don't need AWS credentials or direct Terraform access—they just submit code. The pipeline handles the rest.
The key benefit for platform teams: you can now scale self-service without losing visibility or control. Developers get fast infrastructure provisioning. Your team gets an audit trail and the ability to enforce policies before infrastructure is created.
Guardrails — Policies prevent unsafe configurations
Even with golden modules and good defaults, developers will try to do things you don't want them to do. Someone will try to make a database publicly accessible. Another will skip encryption to save money. A third will provision a massive instance for testing and forget to delete it.
Guardrails are automated policy checks that prevent bad configurations from reaching production. In the Terraform world, this usually means Sentinel (for Terraform Cloud/Enterprise) or Policy as Code engines like OPA/Rego or custom scripts.
A Sentinel policy that prevents public databases looks like this:
import "tfplan/v2" as tfplan
deny_public_database = rule {
all tfplan.resource_changes.aws_db_instance as _, instances {
all instances as _, db {
db.change.after.publicly_accessible == false or
db.change.after.publicly_accessible == null
}
}
}
main = rule {
deny_public_database
}When a developer tries to set publicly_accessible = true, the policy rejects the plan. The message is clear: this violates company policy. The alternative is to go through the platform team for an exception—and that exception gets documented and audited.
Good guardrail policies cover:
- Network exposure. No public databases, no overly permissive security groups.
- Encryption and secrets. All data at rest must be encrypted. Secrets must come from a vault, not hardcoded.
- Compliance tags. All resources must have cost center, owner, and environment tags.
- Resource limits. Prevent developers from accidentally creating massive instances or excessive storage.
- Approved module usage. Require the use of golden modules for common resources instead of raw resource blocks.
Policies should be restrictive but not punitive. They prevent foot-guns, not innovation. If a developer has a legitimate reason to deviate, there should be a clear process to request an exception—one that involves review and creates accountability.
Common mistakes
Mistake 1: Building modules too early. Don't create a golden module until you've deployed the same infrastructure pattern three times. Until then, you don't have enough information to know what should be configurable and what should be fixed.
Mistake 2: Exposing too much flexibility. Module input variables should reflect what developers actually need to vary, not every possible Terraform argument. If developers are never changing a value, it shouldn't be a variable.
Mistake 3: Forgetting to version modules. Always tag releases. Always use git refs in module sources, not branches. A developer using ref=main will break when you change the module. Use semver: ref=v2.3.0.
Mistake 4: Enforcing policies without context. When a policy blocks something, the error message should explain why and how to get an exception. A terse "denied by policy" frustrates developers and makes them distrust your guardrails.
Mistake 5: Skipping the self-service pipeline. If platform engineers are manually running Terraform for developers, you haven't achieved self-service. You've just added a bottleneck. Invest in the pipeline.
Key takeaways
- Golden modules are your single source of truth for common infrastructure patterns—they encode best practices so developers don't have to rediscover them.
- Simple interfaces with safe defaults make modules usable. Developers should only specify what varies, not reimagine the entire architecture.
- Pull request-driven workflows keep everything auditable and give platform teams visibility into what developers are creating.
- Policies as code prevent unsafe configurations before they reach production, but should guide, not punish.
- True self-service means developers provision infrastructure without waiting for platform engineers—but within controlled, predictable boundaries.
- Start with a single golden module for your most common infrastructure need. Expand from there once you've learned what works in your organization.






Comments
Loading comments…