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"
}
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"
}
}
}
}
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
Running yisp build . produces:
mynumber: 8
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
Convert this to JSON using yisp:
yisp build tf.yaml -o json
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"
}
}
}
}
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
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.)
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
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
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
env/dev/index.yaml
Now combine all pieces into a single document:
!yisp
- lists.reduce
- - include
- main.yaml
- ec2.yaml
- maps.merge
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
Update index.yaml to include this patch:
!yisp
- maps.patch
- - lists.reduce
- - include
- main.yaml
- ec2.yaml
- maps.merge
- - include
- ec2.patch.yaml
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
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)