Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 078 / 100

Terraform for platform teams: what developers should know

Give developers self-service infrastructure.

Expona Admin9 min read

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:

hcl
# 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:

hcl
# 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:

hcl
# 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 validation block 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:

  1. Developer creates a pull request in the infrastructure repository with a new Terraform configuration for their service.
  2. Automated checks run: terraform fmt, terraform validate, tflint, policy checks (we'll cover that next).
  3. Humans review the PR—typically a platform engineer.
  4. On merge, a CI/CD pipeline runs terraform plan, shows the output in a comment, and waits for approval.
  5. An authorized platform engineer approves, and the pipeline runs terraform apply.

Here's a simplified GitHub Actions workflow:

hcl
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:

hcl
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles