DEV Community

totegamma
totegamma

Posted on

Bringing kustomize-like Patch Workflows to Terraform Using YAML

Introduction

Terraform is a standard tool for infrastructure management, but once multiple environments (such as dev and prod) are involved, handling environment-specific differences can quickly become challenging.

A common approach is to create shared modules and use them across multiple tenants. However, if you need to enable a special option only for certain environments, you often end up having to modify the module itself, which is not ideal.

If Terraform had something like Kubernetes’ kustomize—where you can apply patches per environment—life would be much easier. Unfortunately, Terraform offers no built-in mechanism for this.

In this article, I’ll introduce an experiment where Terraform configuration is authored in YAML, and then patched per environment before being rendered into Terraform JSON.
This approach makes environment-specific diff management easier and improves code reusability.
Practicality aside, I hope this serves as an interesting example of an alternative Terraform workflow.

A Quick Refresher on Terraform’s JSON Syntax

Terraform configuration is typically written in HCL (.tf files), but did you know it can also be expressed in JSON?

For example, the following .tf file:

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "2.4.0"
    }
  }
}

provider "local" {}

resource "local_file" "hello1" {
  content  = "1st Hello, World!"
  filename = "hello1.txt"
}

resource "local_file" "hello2" {
  content  = "2nd Hello, World!"
  filename = "hello2.txt"
}
Enter fullscreen mode Exit fullscreen mode

can be written in JSON like this:

{
  "provider": {
    "local": {}
  },
  "resource": {
    "local_file": {
      "hello1": {
        "content": "1st Hello, World!",
        "filename": "hello1.txt"
      },
      "hello2": {
        "content": "2nd Hello, World!",
        "filename": "hello2.txt"
      }
    }
  },
  "terraform": {
    "required_providers": {
      "local": {
        "source": "hashicorp/local",
        "version": "2.4.0"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you save this as <filename>.tf.json, Terraform will accept it just like regular HCL when running terraform apply.

However, authoring this JSON manually is a bit cumbersome. It would be much easier if we could write it in YAML and convert it to JSON.

yisp

yisp is a tool that allows you to embed evaluable expressions inside YAML.
For example, given the following YAML:

mynumber: !yisp
  - add
  - 5
  - 3
Enter fullscreen mode Exit fullscreen mode

Running yisp build . produces:

mynumber: 8
Enter fullscreen mode Exit fullscreen mode

yisp also supports features such as merging multiple YAML files, merging objects, and exporting the result as JSON.

In this article, we’ll use yisp to make Terraform JSON structures easier to write in YAML and to manage environment-specific differences.

First, Let’s Try It Out

Here’s a simple Terraform configuration that provisions a single EC2 instance, but written in YAML:

terraform:
  required_providers:
    aws:
      source: hashicorp/aws
      version: ">= 3.0.0"
provider:
  aws:
    region: ap-northeast-1
data:
  aws_ami:
    amazon_linux:
      most_recent: true
      owners:
        - "137112412989"  # Amazon
      filter:
        - name: name
          values:
            - "amzn2-ami-hvm-*-x86_64-gp2"
resource:
  aws_instance:
    example:
      ami: "${data.aws_ami.amazon_linux.id}"
      instance_type: t2.micro
      tags:
        Name: example-instance
Enter fullscreen mode Exit fullscreen mode

Convert this to JSON using yisp:

yisp build tf.yaml -o json
Enter fullscreen mode Exit fullscreen mode

The output will look like this:

{
  "data": {
    "aws_ami": {
      "amazon_linux": {
        "filter": [
          {
            "name": "name",
            "values": [
              "amzn2-ami-hvm-*-x86_64-gp2"
            ]
          }
        ],
        "most_recent": true,
        "owners": [
          "137112412989"
        ]
      }
    }
  },
  "provider": {
    "aws": {
      "region": "ap-northeast-1"
    }
  },
  "resource": {
    "aws_instance": {
      "example": {
        "ami": "${data.aws_ami.amazon_linux.id}",
        "instance_type": "t2.micro",
        "tags": {
          "Name": "example-instance"
        }
      }
    }
  },
  "terraform": {
    "required_providers": {
      "aws": {
        "source": "hashicorp/aws",
        "version": "\u003e= 3.0.0"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Save this as rendered.tf.json, and then run terraform init and terraform apply as usual.

Creating a Makefile

Terraform unfortunately cannot take manifests via stdin, so a file must exist before running commands.
To simplify this, you can set up a Makefile so the JSON is automatically generated before terraform plan or apply.

SOURCES := $(wildcard *.yaml)

rendered.tf.json: $(SOURCES)
    yisp build . -o json > rendered.tf.json

init: rendered.tf.json
    terraform init

plan: rendered.tf.json
    terraform plan

apply: rendered.tf.json
    terraform apply
Enter fullscreen mode Exit fullscreen mode

Turning It Into a Module

Next, let’s modularize this setup so both dev and prod environments can call it with different parameters—similar to how Kubernetes + kustomize structures manifests.

Here’s the directory structure:

📁 .
├── 📁 base
│   └── 📄 ec2.yaml
└── 📁 env
    ├── 📁 dev
    │   ├── 📄 index.yaml
    │   └── 📄 main.yaml
    │   ├── 📄 ec2.yaml
    └── 📁 prod (etc.)
Enter fullscreen mode Exit fullscreen mode

base/ec2.yaml

Let’s suppose we want a module that provisions an EC2 instance from a shared AMI.

!yisp &default
- lambda
- [props]
- !quote
  data:
    aws_ami:
      amazon_linux:
        most_recent: true
        owners:
          - "137112412989"
        filter:
          - name: name
            values:
              - "amzn2-ami-hvm-*-x86_64-gp2"
  resource:
    aws_instance: !yisp
      - maps.make
      - *props.name
      - !quote
        ami: "${data.aws_ami.amazon_linux.id}"
        instance_type: !yisp [default, *props.instance_type, "t2.micro"]
        tags:
          Name: *props.name
Enter fullscreen mode Exit fullscreen mode

This uses a lambda expression to accept a props object, then generates a resource using its fields.

env/dev/ec2.yaml

Now let’s call the module from the dev environment to create two EC2 instances:

!yisp
- import
- [ec2, ../base/ec2.yaml]
---
!yisp
- *ec2.default
- name: dev-server
  instance_type: t3.medium
---
!yisp
- *ec2.default
- name: dev-database
  instance_type: t3.medium
Enter fullscreen mode Exit fullscreen mode

Running yisp build ec2.yaml yields YAML with two instances defined.

env/dev/main.yaml

Basic Terraform config for dev:

terraform:
  required_providers:
    aws:
      source: hashicorp/aws
      version: ">= 3.0.0"
provider:
  aws:
    region: ap-northeast-1
Enter fullscreen mode Exit fullscreen mode

env/dev/index.yaml

Now combine all pieces into a single document:

!yisp
- lists.reduce
- - include
  - main.yaml
  - ec2.yaml
- maps.merge
Enter fullscreen mode Exit fullscreen mode

Running yisp build . merges them into one consolidated Terraform JSON structure.

Applying Patches to Specific Resources

Suppose we want to enable monitoring only for the dev-database instance.
Since we aren’t modifying the base module directly, we can apply a patch at the environment level.

Create ec2.patch.yaml:

op: add
path: "/resource/aws_instance/dev-database/monitoring"
value: true
Enter fullscreen mode Exit fullscreen mode

Update index.yaml to include this patch:

!yisp
- maps.patch
- - lists.reduce
  - - include
    - main.yaml
    - ec2.yaml
  - maps.merge
- - include
  - ec2.patch.yaml
Enter fullscreen mode Exit fullscreen mode

Running yisp build . produces monitoring: true only on the dev-database instance.

Applying a Patch to All Resources of the Same Type

You may also want to patch all EC2 instances at once.
With yisp’s wildcard support (* in patch paths), this becomes easy.

Extend ec2.patch.yaml:

op: add
path: "/resource/aws_instance/dev-database/monitoring"
value: true
---
op: add
path: "/resource/aws_instance/*/tags"
value:
  Environment: Production
  Owner: TeamA
  Project: ProjectX
Enter fullscreen mode Exit fullscreen mode

Both dev-server and dev-database will now receive the additional tags.

Conclusion

In this article, we explored how to write Terraform configurations in YAML, convert them to JSON, and manage environment-specific differences using yisp’s patching features.

Whether this is production-ready or not is still an open question, but the idea of “absorbing environment differences after the fact”—similar to Kubernetes kustomize—opens up interesting new ways to work with Terraform.

If you’re curious, definitely try experimenting with this workflow!

Top comments (0)