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.
-
One codebase
A single Terraform module set describing every resource shape the portfolio needs, written once, reviewed once.
-
Per-environment YAML
Environment differences live in configuration files, not in forked HCL. Dev, staging and production read the same modules with different inputs.
-
for_each+TF_VAR_envTerraform Enterprise injects the active workspace's environment as a
TF_VAR_envvariable per its standard convention, which the codebase reads asvar.envto filter which resources apply through afor_eachloop. A dev-only resource is simply skipped once code promotes to staging, no code change required. -
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.
buckets:
app-logs:
envs: ["dev", "staging", "prod"]
versioning: true
legacy-cache:
envs: ["staging", "prod"]
versioning: false
scratch-dev-only:
envs: ["dev"]
force_destroy: true
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
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
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.
- Compliance was met without blocking the business.
Every environment runs the same Terraform Enterprise GitOps flow, with no environment-specific Terraform code required to get there. - Standardization became its own deliberate initiative.
Enforcing consistent settings across every resource was tackled on its own terms, instead of something rushed and bundled into the migration. - Naming conventions turned out to matter more than expected.
Once every resource followed the same predictable pattern, other resources could reference them by that pattern instead of a hardcoded ID or ARN baked into the code. - The codebase became genuinely dynamic.
A bucket name, a role name, a security group, each is something the codebase can derive and cross-reference on its own, rather than a value someone typed in once and everyone else copies.
The lasting change: the team no longer needed a Terraform specialist in the room to make a safe change.