Hedronite · Dev Lesson · Polyglot-Dev / HCL · Fri 2026-10-02

HCL jsonencode and templatefile the Encode Shelf for filter patterns

Encode structured values. Feed templates from maps. Stop pasting JSON as a heredoc.

Lesson Class: Dev (HCL depth · jsonencode + templatefile)
Language: HCL
Paired Ops: CloudWatch log group retention + Rust census
Paired Cert: Associate encoding + collection helpers
Grounding: Brikman pp.190-192 · Lab 20 · Lab 27 referenced
jsonencode
HCL value to JSON string for document consumers.
templatefile
On-disk body + vars map. Use path.module in modules.
Encode Shelf
Name the consumer before you encode.
CloudWatch filter grammar is not a JSON document.

<!-- hal:authoritative:yaml -->

Encode structured values. Feed templates from maps. Stop pasting JSON as a heredoc when the language already owns the shape.

§I. Frame

09-20 taught dynamic blocks and for_each on an ingress map. Leave it.

Today tf_day_dev_counter is 21. Twenty-one mod 3 is 0. HCL depth. Ops wants a CloudWatch metric filter whose pattern attribute is a string AWS understands, and eventually IAM-ish documents that must be valid JSON. The grammar question is how that string is produced.

§II. Two encoding tools

**Tool one. jsonencode.** Takes any Terraform value (object, list, string, bool, number) and returns a JSON string. Numbers stay numbers. Booleans stay booleans. Nested objects stay nested. Use it when an AWS attribute expects a JSON document and you already have a map in locals.

locals {
  filter_doc = {
    level = "ERROR"
    app   = var.app_name
  }
}

# Wrong shape for CloudWatch filter grammar, right shape for many IAM/policy attrs:
# policy = jsonencode(local.policy_object)

**Tool two. templatefile.** Reads a file from disk, interpolates ${...} using a vars map, returns a string. Brikman 3e uses it for user_data. Inside a module, call templatefile("${path.module}/user-data.sh", { ... }) so the path resolves relative to the module, not the root.

resource "aws_instance" "example" {
  # teaching adjacency only; Ops tonight is Logs, not EC2
  user_data = templatefile("${path.module}/user-data.sh", {
    app_name = var.app_name
    log_group = aws_cloudwatch_log_group.app.name
  })
}

§III. CloudWatch filter: string grammar, not free JSON paste

The metric filter pattern on aws_cloudwatch_log_metric_filter is CloudWatch filter syntax. A JSON-log match looks like { $.level = "ERROR" }. That is not the same as jsonencode({ level = "ERROR" }), which would emit {"level":"ERROR"} and fail as a filter pattern.

Named technique: Encode Shelf. Before you call jsonencode, name the consumer:

  1. JSON document consumer (IAM policy, ECS task definition fragment, Secrets Manager secret string) → jsonencode of a map.
  2. Template consumer (shell user-data, Prometheus scrape snippet on disk) → templatefile with a vars map.
  3. Domain grammar consumer (CloudWatch filter pattern, Metric Math expression) → a string built for that grammar; do not force jsonencode.
locals {
  # Domain grammar: keep as a deliberate string (or templatefile of a .pattern file).
  error_filter = "{ $.level = \"ERROR\" && $.service = \"${var.service}\" }"

  # JSON document: encode the object; never hand-type braces in a heredoc.
  alarm_dims = jsonencode({
    service = var.service
    env     = var.env
  })
}

resource "aws_cloudwatch_log_metric_filter" "error_rate" {
  name           = "${var.service}-error-count"
  log_group_name = aws_cloudwatch_log_group.app.name
  pattern        = local.error_filter

  metric_transformation {
    name      = "AppErrorCount"
    namespace = "Hedronite/App"
    value     = "1"
  }
}

Rule one. jsonencode is for JSON consumers. CloudWatch filter patterns are not JSON documents.

Rule two. Prefer templatefile over a multi-line heredoc when the body lives on disk and takes parameters. Prefer a short local string when the body is one line of domain grammar.

Rule three. Lab 20 shapes external JSON with jsondecode / file. Encoding is the inverse direction: HCL value → JSON string. Keep the directions straight on the exam and in review.

§III.b. The heredoc trap

A <<EOT block feels honest because you see the braces. It is also where commas go missing and where a reviewer cannot terraform console a single expression. Prefer:

# Prefer
policy = jsonencode({
  Version = "2012-10-17"
  Statement = [{
    Effect   = "Allow"
    Action   = ["logs:CreateLogStream", "logs:PutLogEvents"]
    Resource = ["${aws_cloudwatch_log_group.app.arn}:*"]
  }]
})

# Avoid for JSON documents
# policy = <<EOT
# { "Version": "2012-10-17", ... }
# EOT

Heredocs still win for shell scripts that are mostly literal text with a few interpolations. That is templatefile territory once the script leaves the .tf file. Keep JSON out of heredocs when jsonencode can own the shape.

§III.c. templatefile vars are a map, not free names

The second argument to templatefile is an object/map of names available inside the file. A bare ${app_name} inside the template resolves only if you passed app_name = ... in that map. Root-module variables do not leak into the template automatically.

# user-data.sh contains: echo "shipping to ${log_group}"
user_data = templatefile("${path.module}/user-data.sh", {
  log_group = aws_cloudwatch_log_group.app.name
})

Forget the map entry and Terraform errors at plan with an undefined variable inside the template. That failure is louder than a silent wrong string from a heredoc you forgot to update.

§IV. Close instruction

Take Ops' metric filter. Move the pattern into a local. Parameterize the service name. Confirm terraform console prints the filter string with quotes escaped for HCL, not as jsonencode output. Then write a tiny IAM policy object and jsonencode it for contrast. Pair: Ops owns retention + census; Cert names Associate stems for encoding and collection helpers.

Related