After Day 9, the infrastructure repo looked like this:
terraform-infra/
├── dev/
│ ├── main.tf
│ ├── variables.tf
│ └── terraform.tfvars ← 6 variables
├── staging/
│ ├── main.tf
│ ├── variables.tf
│ └── terraform.tfvars ← same 6 variables, different values
└── prod/
├── main.tf
├── variables.tf
└── terraform.tfvars ← same 6 variables, different values
Three copies of the same variable declarations. When I added enable_monitoring in Day 10, I edited four files: variables.tf in every environment plus each terraform.tfvars. When I add the next feature — say, CloudWatch log retention or ALB access logs — I'll do it again.
This is the config management version of the copy-paste problem. The fix is object types, which let you express the entire per-environment configuration as a single structured variable instead of six flat ones.
That's the first topic today. The others — data blocks, built-in functions, and conditional resource arguments — solve different problems in the same FastAPI setup, and they all connect.
Part 1: Object Types — One Variable Per Environment
The Problem
The current variables.tf in each environment folder:
variable "environment" { type = string }
variable "instance_type" { type = string }
variable "min_size" { type = number }
variable "max_size" { type = number }
variable "enable_monitoring" { type = bool }
variable "health_check_grace_period" { type = number }
And the matching terraform.tfvars in each folder. Three environments means three copies of this, with values scattered across the filesystem.
The Fix: map(object({...}))
Collapse all per-environment config into a single typed map of objects, defined once in the root module:
# root/variables.tf
variable "environment" {
description = "Which environment to deploy"
type = string
}
variable "env_config" {
description = "Full configuration for each environment. Single source of truth."
type = map(object({
instance_type = string
min_size = number
max_size = number
enable_monitoring = bool
health_check_grace_period = number
log_retention_days = number
}))
default = {
dev = {
instance_type = "t2.micro"
min_size = 1
max_size = 2
enable_monitoring = false
health_check_grace_period = 300
log_retention_days = 7
}
staging = {
instance_type = "t2.small"
min_size = 1
max_size = 3
enable_monitoring = false
health_check_grace_period = 300
log_retention_days = 14
}
prod = {
instance_type = "t3.small"
min_size = 2
max_size = 6
enable_monitoring = true
health_check_grace_period = 360
log_retention_days = 90
}
}
}
Then pull the right config block for the current environment in locals:
# root/main.tf
locals {
cfg = var.env_config[var.environment]
}
module "web_app" {
source = "git::https://github.com/mohamednourdine/terraform-modules.git//modules/web-app?ref=v1.3.0"
environment = var.environment
instance_type = local.cfg.instance_type
min_size = local.cfg.min_size
max_size = local.cfg.max_size
enable_monitoring = local.cfg.enable_monitoring
health_check_grace_period = local.cfg.health_check_grace_period
user_data = local.fastapi_user_data
}
Deploying dev is now:
terraform apply -var="environment=dev"
Deploying prod is:
terraform apply -var="environment=prod"
No tfvars files per environment. No repeated variable declarations. Adding a new config key — say, deletion_protection = bool — means one addition to the object({}) type definition and one value per environment in the default map.
Why the type constraint matters
The object({...}) declaration isn't just documentation — Terraform enforces it. If you add deletion_protection to the type definition but forget to add it in the dev block, you get an error before any resources are created:
Error: Invalid value for input variable
The given value is not suitable for var.env_config: element "dev": attribute
"deletion_protection" is required.
Compare that to flat variables, where a missing tfvars entry just silently uses the default. The object type makes the contract explicit and validated.
Part 2: Data Blocks — Let AWS Tell You What It Already Knows
The Problem
The Day 9 setup had hardcoded values that should have been dynamic:
- The AMI ID was fetched dynamically with a
datablock — good. - The AWS account ID wasn't — it was referenced indirectly or skipped.
- The region was hardcoded to
us-east-1in the backend and provider.
If someone forks this and deploys to eu-west-1, the bucket names and ARN references break.
aws_caller_identity and aws_region
These two data sources give you the current AWS account ID and region without any parameters:
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
Use them in locals to build globally-unique, region-aware names:
locals {
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.name
# S3 bucket names must be globally unique across all AWS accounts.
# Embedding the account ID and region guarantees uniqueness without
# manually tracking what names are taken.
log_bucket_name = "fastapi-logs-${local.account_id}-${local.region}-${var.environment}"
}
Now the bucket name is unique per account, per region, per environment — and you never touch the code if you deploy somewhere new.
aws_iam_policy_document — conditionals inside data blocks
Data sources aren't just for reading existing resources. aws_iam_policy_document generates IAM policy JSON from HCL, and it supports conditional statements natively:
data "aws_iam_policy_document" "ec2_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "instance_permissions" {
# Always allow reading from the app config bucket
statement {
effect = "Allow"
actions = ["s3:GetObject"]
resources = [
"arn:aws:s3:::fastapi-config-${local.account_id}/*"
]
}
# Only allow writing to the log bucket in non-dev environments
dynamic "statement" {
for_each = var.environment != "dev" ? [1] : []
content {
effect = "Allow"
actions = ["s3:PutObject"]
resources = [
"arn:aws:s3:::${local.log_bucket_name}/*"
]
}
}
}
resource "aws_iam_role" "instance" {
name = "${local.name_prefix}-instance-role"
assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
}
resource "aws_iam_role_policy" "instance" {
name = "${local.name_prefix}-permissions"
role = aws_iam_role.instance.id
policy = data.aws_iam_policy_document.instance_permissions.json
}
The dynamic "statement" trick — iterating over [1] (truthy) or [] (empty, skipped) — is the standard pattern for optional blocks inside data sources and resources.
Part 3: Built-in Functions That Actually Come Up
Terraform has dozens of built-in functions. These are the ones that appear regularly when working with the FastAPI setup.
lookup() — safe map access with a default
variable "extra_tags" {
type = map(string)
default = {}
}
locals {
# Returns the value for "CostCenter" if it exists, otherwise "unassigned"
cost_center = lookup(var.extra_tags, "CostCenter", "unassigned")
}
Use this instead of var.extra_tags["CostCenter"], which panics if the key is missing.
coalesce() — first non-null, non-empty value
variable "custom_alarm_name" {
type = string
default = ""
}
locals {
# Use the custom name if provided, otherwise generate one
alarm_name = coalesce(var.custom_alarm_name, "${local.name_prefix}-high-cpu")
}
coalesce() takes multiple arguments and returns the first one that isn't null or empty string. Useful for "override or default" patterns without a ternary chain.
contains() — conditional logic from list membership
variable "environment" {
type = string
}
locals {
# True for any production-like environment
is_production = contains(["prod", "production"], var.environment)
# Use this instead of var.environment == "prod" —
# handles multiple acceptable values cleanly
instance_type = local.is_production ? "t3.small" : "t2.micro"
}
This is cleaner than chained ternaries when the condition is "is this value in a set."
merge() — combining maps without repetition
The Day 10 blog showed this for security group rules. Here's a more complete pattern for tags:
variable "extra_tags" {
type = map(string)
default = {}
}
locals {
base_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = "fastapi-demo"
}
# Caller-supplied tags override base tags if keys conflict
all_tags = merge(local.base_tags, var.extra_tags)
}
Pass all_tags to every resource. The caller can override any base tag or add new ones without touching the module.
format() and formatlist() — string building
locals {
# Equivalent to Python's f-string
alarm_description = format(
"CPU > %d%% for %d consecutive periods on %s",
80, # threshold
2, # evaluation_periods
local.name_prefix
)
# Apply the same format to a list
bucket_names = formatlist("fastapi-%s-${local.account_id}", ["logs", "backups", "assets"])
# → ["fastapi-logs-123456789", "fastapi-backups-123456789", "fastapi-assets-123456789"]
}
try() — safe access to things that might not exist
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
count = local.cfg.enable_monitoring ? 1 : 0
# ...
}
output "alarm_arn" {
description = "CloudWatch alarm ARN (null if monitoring is disabled)"
# Without try(), this panics when count = 0 because index [0] doesn't exist
value = try(aws_cloudwatch_metric_alarm.high_cpu[0].arn, null)
}
try() evaluates expressions left to right and returns the first one that doesn't error. It's the safe way to reference resources that might have count = 0.
Part 4: Conditional Resource Arguments — Beyond count = 0
Day 10 showed how to use count = condition ? 1 : 0 to create or skip a resource entirely. But conditionals also work inside resource arguments — controlling how a resource is configured, not just whether it exists.
CloudWatch Log Group with conditional retention
resource "aws_cloudwatch_log_group" "api" {
name = "/fastapi/${var.environment}/app"
# Retention varies by environment. prod keeps logs for 90 days (compliance).
# Staging keeps 14. Dev keeps 7 to avoid storage costs.
# 0 means "never expire" — never use this in a shared account.
retention_in_days = local.cfg.log_retention_days
tags = local.all_tags
}
The log group always exists, but its configuration varies by environment — all driven by the env_config object type from Part 1.
ALB access logs — optional bucket and conditional attachment
Access logging on the ALB is useful for debugging but adds S3 cost. Enable it in prod, skip it in dev and staging.
# The access log bucket — only created when monitoring is enabled
resource "aws_s3_bucket" "alb_logs" {
count = local.cfg.enable_monitoring ? 1 : 0
bucket = local.log_bucket_name
tags = local.all_tags
}
resource "aws_s3_bucket_lifecycle_configuration" "alb_logs" {
count = local.cfg.enable_monitoring ? 1 : 0
bucket = aws_s3_bucket.alb_logs[0].id
rule {
id = "expire-old-logs"
status = "Enabled"
expiration {
days = local.cfg.log_retention_days
}
}
}
resource "aws_lb" "web" {
name = "${local.name_prefix}-alb"
load_balancer_type = "application"
subnets = data.aws_subnets.default.ids
security_groups = [aws_security_group.alb.id]
# Conditional nested block — access_logs is present or empty based on monitoring flag
dynamic "access_logs" {
for_each = local.cfg.enable_monitoring ? [1] : []
content {
bucket = aws_s3_bucket.alb_logs[0].bucket
prefix = "${var.environment}/alb"
enabled = true
}
}
tags = local.all_tags
}
Three things working together here:
counton the S3 bucket — it only exists in monitoring-enabled environmentsdynamic "access_logs"with[1]or[]— the nested block exists or is omittedaws_s3_bucket.alb_logs[0]referenced safely because thedynamicblock only runs when the bucket exists
Conditional instance profile
The EC2 instances in the FastAPI ASG need an IAM instance profile to call AWS services. In dev you might skip this to keep things simple. In prod it's required.
resource "aws_iam_instance_profile" "app" {
count = local.is_production ? 1 : 0
name = "${local.name_prefix}-instance-profile"
role = aws_iam_role.instance[0].name
}
resource "aws_launch_template" "web" {
image_id = data.aws_ami.amazon_linux.id
instance_type = local.cfg.instance_type
vpc_security_group_ids = [aws_security_group.instance.id]
# iam_instance_profile block only present in prod
dynamic "iam_instance_profile" {
for_each = local.is_production ? [1] : []
content {
name = aws_iam_instance_profile.app[0].name
}
}
user_data = base64encode(var.user_data)
lifecycle {
create_before_destroy = true
}
}
Part 5: Putting It All Together
Here's the complete root module, combining every technique from this post:
# root/main.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "mnourdine-tf-state"
key = "fastapi/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
provider "aws" {
region = "us-east-1"
}
# ── Data sources ─────────────────────────────────────────────────────────────
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# ── Locals ────────────────────────────────────────────────────────────────────
locals {
cfg = var.env_config[var.environment]
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.name
name_prefix = "fastapi-${var.environment}"
is_production = contains(["prod", "production"], var.environment)
log_bucket_name = "fastapi-logs-${local.account_id}-${local.region}-${var.environment}"
base_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = "fastapi-demo"
}
all_tags = merge(local.base_tags, var.extra_tags)
}
# ── Module ────────────────────────────────────────────────────────────────────
module "web_app" {
source = "git::https://github.com/mohamednourdine/terraform-modules.git//modules/web-app?ref=v1.3.0"
environment = var.environment
instance_type = local.cfg.instance_type
min_size = local.cfg.min_size
max_size = local.cfg.max_size
enable_monitoring = local.cfg.enable_monitoring
health_check_grace_period = local.cfg.health_check_grace_period
server_port = 8000
health_check_path = "/health"
user_data = local.fastapi_user_data
}
# ── CloudWatch Log Group ──────────────────────────────────────────────────────
resource "aws_cloudwatch_log_group" "api" {
name = "/fastapi/${var.environment}/app"
retention_in_days = local.cfg.log_retention_days
tags = local.all_tags
}
# ── ALB Access Logs (prod only) ───────────────────────────────────────────────
resource "aws_s3_bucket" "alb_logs" {
count = local.cfg.enable_monitoring ? 1 : 0
bucket = local.log_bucket_name
tags = local.all_tags
}
resource "aws_s3_bucket_lifecycle_configuration" "alb_logs" {
count = local.cfg.enable_monitoring ? 1 : 0
bucket = aws_s3_bucket.alb_logs[0].id
rule {
id = "expire-old-logs"
status = "Enabled"
expiration { days = local.cfg.log_retention_days }
}
}
# ── Outputs ───────────────────────────────────────────────────────────────────
output "url" {
value = "http://${module.web_app.alb_dns_name}"
description = "Base URL of the FastAPI app"
}
output "log_group" {
value = aws_cloudwatch_log_group.api.name
description = "CloudWatch log group for the API"
}
output "alb_log_bucket" {
description = "S3 bucket for ALB access logs (null in dev/staging)"
value = try(aws_s3_bucket.alb_logs[0].bucket, null)
}
Deploy dev — one command, no tfvars file needed since dev is in the defaults:
terraform apply -var="environment=dev"
Deploy prod:
terraform apply -var="environment=prod"
The diff between the two:
- Prod gets a CloudWatch alarm, an ALB access log bucket, and a log lifecycle policy
- Prod retains logs for 90 days; dev for 7
- Prod uses
t3.smallwith 2–6 instances; dev usest2.microwith 1–2
Everything driven by var.environment selecting the right object from var.env_config. No separate tfvars files, no drift between environment configs.
Gotchas
object({}) doesn't allow extra keys. If you pass an object with a field that isn't in the type definition, Terraform errors. This is actually the behavior you want — it prevents typos from silently being ignored. But it means you can't use for_each over env_config and pass the whole object to a module; you have to map fields explicitly.
try() hides real errors. try(some_resource[0].attr, null) will return null for any error — not just "index out of bounds." If some_resource has a bug that causes an attribute to be wrong, try() will swallow it. Use it specifically for the count = 0 case, not as a general error suppresser.
contains() is case-sensitive. contains(["prod"], "Prod") returns false. Either normalize the input with lower(var.environment) or be consistent about casing.
data "aws_caller_identity" needs IAM permissions. The sts:GetCallerIdentity call is free and usually allowed by default, but in locked-down accounts or with restricted IAM roles it can fail. Worth knowing if you hit a mysterious permissions error on terraform plan.
Conditional resource attributes can't reference resources with count = 0. You can't write bucket = aws_s3_bucket.alb_logs.bucket when the bucket might not exist — Terraform evaluates this regardless of the condition. You must use the index form aws_s3_bucket.alb_logs[0].bucket inside a block that only runs when the bucket exists (like the dynamic block above).
What Each Technique Is For
| Technique | Problem it solves |
|---|---|
map(object({...})) |
Config scattered across N tfvars files for N environments |
data "aws_caller_identity" |
Hardcoded account IDs and bucket names |
lookup(map, key, default) |
Safe map access where the key might not exist |
coalesce(a, b, c) |
"Use override if set, otherwise use default" patterns |
contains(list, value) |
Conditions based on list membership, not equality |
merge(map1, map2) |
Tags and maps where callers can override or extend |
try(expr, fallback) |
Safe references to resources with count = 0 |
dynamic "block" + [1]/[] |
Optional nested blocks inside a resource |
| Conditional resource argument | Resource always exists but configured differently per env |
Where I'm At
The FastAPI deployment is now genuinely environment-aware rather than just environment-parameterized. The difference: environment-parameterized means the same resources with different values. Environment-aware means different resources, different policies, different retention periods — selected automatically by the config.
The object type variable is the piece that makes this clean. Once all per-environment config lives in one typed map, adding a new dimension (say, enable_deletion_protection = bool for RDS later) is one change in one place, with Terraform enforcing that all environments declare it.
Next up: Terraform workspaces — the built-in alternative to this directory-per-environment approach, and when each makes more sense.
This post is part of a 30-day Terraform learning journey.
💬 Comments
No comments yet. Be the first to share your thoughts!
Leave a Comment