Case study · PT HM Sampoerna Tbk.

YAML-based Terraform Standardization

Global compliance required GitOps-based provisioning. What we inherited on the flagship application was click-ops: naming drift and environment-specific snowflakes everywhere. Both things had to be true at once, without stopping delivery.

Context

PT HM Sampoerna is part of Philip Morris International, which means its cloud estate answers to PMI's global security and governance standards, not just local ones. I joined as Senior DevOps Engineer owning SLAs for the application portfolio, and led a team of seven engineers through the migration.

The mandate: move the company's flagship, revenue-critical application off console-based provisioning and onto Terraform, with a consistent GitOps branching strategy across dev, staging, and production.

This was purely an IT-side activity. The business doesn't concern itself with how infrastructure gets migrated, only that the application keeps working. That framing makes any downtime a hard no, even inside a scheduled maintenance window, because as far as the business is concerned there's no such thing as an acceptable time for the app to be unavailable.

Challenge

Inherited estates are rarely clean. This one had accumulated years of console-era decisions on the flagship application: inconsistent naming, stateful or hard-to-replace resources like IAM roles and policies, KMS encryption keys, and production databases, where an unintended destroy-and-recreate cycle risked data loss or downtime. Terraform's default behavior treats configuration drift as a trigger to replace a resource, which conflicted directly with a zero-downtime requirement from the business.

Compounding this, resource existence itself was inconsistent across environments: some resources existed in dev and staging but not production, others only in one environment, a byproduct of ad hoc provisioning from before the migration began. The obvious approach, writing Terraform per environment, would have meant three times the code and three times the drift risk. You end up with three codebases that diverge quietly, and a team where only two people can safely change any of them.

None of this happened against a frozen snapshot, either. Resource creation on the legacy console-based side kept going the entire time, change requests and feature development didn't pause just because a migration was underway. Whatever the codebase looked like at the start of the week wasn't a safe assumption by the end of it.

An as-is migration, lifting every inconsistency exactly as it stood into Terraform, was technically possible. It just wasn't something the team could live with going forward. Standardization couldn't be a follow-up project promised for later; it had to be part of the migration itself, or the same drift would just get a GitOps stamp on it and keep going.

Compliance wanted every resource under GitOps, no exceptions. Operations wanted to keep shipping against a moving target without a freeze. A three-codebase HCL sprawl would have satisfied the first and destroyed the second: three times the code, three times the drift, chasing a legacy side that never stopped changing.

Action

Rather than hand-writing Terraform per environment, I proposed a config-based approach: a single Terraform codebase driven by per-environment YAML configuration, with each resource looped over via for_each and filtered by a dedicated environment flag, so the same reviewed HCL definition could serve every environment instead of tripling the codebase, and the drift risk that came with it.

  1. One codebase

    A single Terraform module set describing every resource shape the portfolio needs, written once, reviewed once.

  2. Per-environment YAML

    Environment differences live in configuration files, not in forked HCL. Dev, staging and production read the same modules with different inputs.

  3. for_each + TF_VAR_env

    Terraform Enterprise injects the active workspace's environment as a TF_VAR_env variable per its standard convention, which the codebase reads as var.env to filter which resources apply through a for_each loop. A dev-only resource is simply skipped once code promotes to staging, no code change required.

  4. GitOps branching

    Dev, staging and production map to branches with the review gates compliance required, making promotion a reviewable diff rather than a console action.

config.yaml [SAMPLE]
buckets:
  app-logs:
    envs: ["dev", "staging", "prod"]
    versioning: true
  legacy-cache:
    envs: ["staging", "prod"]
    versioning: false
  scratch-dev-only:
    envs: ["dev"]
    force_destroy: true
main.tf [SAMPLE]
variable "env" {
  type        = string
  description = "Target environment, injected by
  Terraform Enterprise as TF_VAR_env per workspace"
}

locals {
  config = yamldecode(file("${path.module}/config.yaml"))

  buckets = {
    for name, cfg in local.config.buckets :
    name => cfg if contains(cfg.envs, var.env)
  }
}

resource "aws_s3_bucket" "this" {
  for_each = local.buckets

  bucket        = "${each.key}-${var.env}"
  force_destroy = try(each.value.force_destroy, false)

  tags = {
    Environment = var.env
  }
}

The Terraform code itself only had to be written once, and a routine change never touches a .tf file, only the YAML configuration, cutting the chance of accidental breakage. The result that mattered most operationally: an engineer who doesn't know HCL can still safely change infrastructure, because the thing they edit is a YAML value with a reviewable diff.

Tech Stack

IaC
Terraform Terraform Enterprise
Config
YAML GitOps
CI/CD
Bitbucket

In practice, a change starts as a YAML edit on a feature branch, adding a bucket, adjusting a setting, whatever the request is. Bitbucket is just the codebase: it hosts the repo and the pull request. The pipeline itself runs on Terraform Enterprise, opening that PR triggers a plan there against the target environment, injecting the workspace's TF_VAR_env and scoping the run to only the resources the YAML says belong to it. That plan is where a human actually sits in the loop: Terraform Enterprise surfaces the diff, and the PR only gets merged into dev, then staging, then production, once someone has looked at that diff and approved the pipeline. Creating a brand-new resource follows the same path: add it to config.yaml with the environments it should exist in, and the for_each loop picks it up automatically, no new .tf file to write or review.

Results

100% of the flagship application's resources on Terraform

Every resource on the flagship application, across dev, staging, and production, now lives in the same GitOps-managed Terraform codebase. Nothing was left running off console-based provisioning as an exception to migrate later.

The lasting change: the team no longer needed a Terraform specialist in the room to make a safe change.