Terraform data
Terraform for data infrastructure — S3/MinIO data lake buckets (versioning, lifecycle, SSE-KMS), IAM roles for Spark/Airflow (least-privilege, IRSA on EKS), MSK/Kafka clusters (aws_msk_cluster, encryption, custom broker config), Kubernetes data platform (helm_release Airflow + Spark, namespace resource quotas), module layout (modules/ + envs/), typed variables with validation, S3 remote state + DynamoDB locking, Terragrunt DRY configs, GitHub Actions CI/CD pipeline with plan/applyFrom its SKILL.md
npx -y skills add ivanshamaev/de-agent-skills --skill terraform_dataAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 15 stars15 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
38.6 KB, ~10.1k tokens by cl100k_base, as published. Nobody here has run it
Terraform for Data Infrastructure
When to Use
Load this skill when the user needs to:
- Provision S3 or MinIO buckets for a data lake (versioning, lifecycle transitions, SSE-KMS encryption, bucket policies)
- Create IAM roles and policies for Spark, Airflow, or other data workloads with least-privilege access; configure IRSA on EKS
- Create or manage Amazon MSK (Managed Streaming for Kafka) clusters or Confluent Cloud Kafka clusters
- Deploy Airflow, Spark History Server, or other data tools on Kubernetes using
helm_release - Structure a Terraform monorepo with reusable modules and per-environment
tfvars - Manage Terraform state with S3 + DynamoDB locking or Terraform Cloud
- Set up GitHub Actions CI/CD for
terraform plan/apply, or adopt Terragrunt for DRY multi-environment configs - Write typed variables with validation, sensitive outputs, and
localsfor computed values
Project Structure
A scalable layout separates reusable modules from environment-specific entry points:
infra/
├── modules/
│ ├── s3-lake/ # S3 bucket + lifecycle + encryption + policy
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── kafka/ # MSK cluster + configuration + security groups
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── k8s-data/ # Helm releases (Airflow, Spark History), namespaces, quotas
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
│
├── envs/
│ ├── dev/
│ │ ├── main.tf # calls modules with dev values
│ │ ├── backend.tf # remote state — dev prefix
│ │ ├── terraform.tfvars
│ │ └── variables.tf
│ └── prod/
│ ├── main.tf
│ ├── backend.tf # remote state — prod prefix
│ ├── terraform.tfvars
│ └── variables.tf
│
└── terragrunt/ # optional DRY wrapper (see Terragrunt section)
├── terragrunt.hcl
├── dev/
│ └── s3-lake/terragrunt.hcl
└── prod/
└── s3-lake/terragrunt.hcl
backend.tf — S3 Remote State with DynamoDB Locking
# envs/prod/backend.tf
terraform {
backend "s3" {
bucket = "my-company-tf-state"
key = "prod/data-platform/terraform.tfstate"
region = "us-east-1"
encrypt = true
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123"
dynamodb_table = "terraform-state-lock" # PAY_PER_REQUEST billing mode
}
}
Bootstrap the state bucket once (outside Terraform, to avoid the chicken-and-egg problem):
aws s3api create-bucket --bucket my-company-tf-state --region us-east-1
aws s3api put-bucket-versioning \
--bucket my-company-tf-state \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket my-company-tf-state \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"}}]}'
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
S3 / MinIO Buckets for the Data Lake
modules/s3-lake/variables.tf
variable "bucket_name" {
type = string
description = "Globally unique S3 bucket name."
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{2,61}[a-z0-9]$", var.bucket_name))
error_message = "Bucket name must be 4-63 lowercase characters, digits, hyphens, or dots."
}
}
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
variable "kms_key_arn" {
type = string
description = "ARN of the KMS CMK for SSE-KMS. If empty, SSE-S3 (AES-256) is used."
default = ""
sensitive = false
}
variable "glacier_transition_days" {
type = number
default = 90
description = "Days before transitioning non-current versions to Glacier Instant Retrieval."
}
variable "expiration_days" {
type = number
default = 365
description = "Days before expiring non-current object versions entirely."
}
variable "allowed_role_arns" {
type = list(string)
description = "IAM role ARNs allowed to read/write this bucket."
default = []
}
modules/s3-lake/main.tf
locals {
use_kms = var.kms_key_arn != ""
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Module = "s3-lake"
}
}
resource "aws_s3_bucket" "lake" {
bucket = var.bucket_name
force_destroy = var.environment != "prod" # protect prod from accidental deletion
tags = local.common_tags
}
resource "aws_s3_bucket_versioning" "lake" {
bucket = aws_s3_bucket.lake.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "lake" {
bucket = aws_s3_bucket.lake.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = local.use_kms ? "aws:kms" : "AES256"
kms_master_key_id = local.use_kms ? var.kms_key_arn : null
}
bucket_key_enabled = local.use_kms # reduce KMS API calls / cost
}
}
resource "aws_s3_bucket_lifecycle_configuration" "lake" {
bucket = aws_s3_bucket.lake.id
rule {
id = "transition-noncurrent-to-glacier"
status = "Enabled"
filter { prefix = "" } # applies to all objects
noncurrent_version_transition {
noncurrent_days = var.glacier_transition_days
storage_class = "GLACIER_IR" # Glacier Instant Retrieval — ms restore latency
}
noncurrent_version_expiration {
noncurrent_days = var.expiration_days
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}
resource "aws_s3_bucket_public_access_block" "lake" {
bucket = aws_s3_bucket.lake.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
data "aws_iam_policy_document" "lake_policy" {
# Deny unencrypted uploads
statement {
sid = "DenyNonEncryptedUploads"
effect = "Deny"
actions = ["s3:PutObject"]
principals {
type = "*"
identifiers = ["*"]
}
resources = ["${aws_s3_bucket.lake.arn}/*"]
condition {
test = "StringNotEquals"
variable = "s3:x-amz-server-side-encryption"
values = local.use_kms ? ["aws:kms"] : ["AES256"]
}
}
# Allow specified roles
dynamic "statement" {
for_each = length(var.allowed_role_arns) > 0 ? [1] : []
content {
sid = "AllowDataWorkloads"
effect = "Allow"
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
principals {
type = "AWS"
identifiers = var.allowed_role_arns
}
resources = [
aws_s3_bucket.lake.arn,
"${aws_s3_bucket.lake.arn}/*",
]
}
}
}
resource "aws_s3_bucket_policy" "lake" {
bucket = aws_s3_bucket.lake.id
policy = data.aws_iam_policy_document.lake_policy.json
}
modules/s3-lake/outputs.tf
output "bucket_id" { value = aws_s3_bucket.lake.id }
output "bucket_arn" { value = aws_s3_bucket.lake.arn }
output "bucket_regional_domain_name" {
value = aws_s3_bucket.lake.bucket_regional_domain_name
}
MinIO Provider (On-Prem)
For on-premises deployments use the minio community provider — API-compatible with S3:
terraform {
required_providers {
minio = {
source = "aminueza/minio"
version = "~> 2.5"
}
}
}
provider "minio" {
minio_server = var.minio_endpoint # e.g. "minio.internal:9000"
minio_user = var.minio_access_key
minio_password = var.minio_secret_key
minio_ssl = false
}
resource "minio_s3_bucket" "bronze" {
bucket = "bronze"
acl = "private"
}
resource "minio_s3_bucket_versioning" "bronze" {
bucket = minio_s3_bucket.bronze.bucket
versioning_configuration {
status = "Enabled"
}
}
# MinIO bucket policy (JSON, same as AWS)
resource "minio_iam_policy" "spark_rw" {
name = "spark-rw-bronze"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [
"arn:aws:s3:::bronze",
"arn:aws:s3:::bronze/*",
]
}]
})
}
IAM for Data Workloads
Least-Privilege Role for Spark on EC2 / EMR
# modules/iam-spark/main.tf
data "aws_iam_policy_document" "spark_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "spark" {
name = "${var.environment}-spark-role"
assume_role_policy = data.aws_iam_policy_document.spark_assume_role.json
tags = local.common_tags
}
data "aws_iam_policy_document" "spark_s3" {
statement {
sid = "ReadWriteLake"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation",
]
resources = [
var.lake_bucket_arn,
"${var.lake_bucket_arn}/*",
]
}
statement {
sid = "AllowKMSForLake"
effect = "Allow"
actions = ["kms:Decrypt", "kms:GenerateDataKey"]
resources = [var.kms_key_arn]
condition {
test = "StringEquals"
variable = "kms:ViaService"
values = ["s3.${var.region}.amazonaws.com"]
}
}
# Glue / Hive metastore — read-only
statement {
sid = "GlueReadOnly"
effect = "Allow"
actions = [
"glue:GetDatabase", "glue:GetDatabases",
"glue:GetTable", "glue:GetTables",
"glue:GetPartition", "glue:GetPartitions",
]
resources = ["*"]
}
}
resource "aws_iam_role_policy" "spark_s3" {
name = "spark-s3-access"
role = aws_iam_role.spark.id
policy = data.aws_iam_policy_document.spark_s3.json
}
resource "aws_iam_instance_profile" "spark" {
name = "${var.environment}-spark-instance-profile"
role = aws_iam_role.spark.name
}
IRSA — IAM Roles for Service Accounts (EKS)
IRSA lets a Kubernetes ServiceAccount assume an IAM role without long-lived credentials. The pod's JWT token is exchanged for AWS credentials via the cluster OIDC provider.
# Get the EKS cluster OIDC issuer URL
data "aws_eks_cluster" "this" {
name = var.eks_cluster_name
}
data "aws_iam_openid_connect_provider" "eks" {
url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
}
locals {
oidc_provider_arn = data.aws_iam_openid_connect_provider.eks.arn
# Strip "https://" prefix for condition matching
oidc_provider_id = replace(data.aws_eks_cluster.this.identity[0].oidc[0].issuer, "https://", "")
}
# ─── Airflow IRSA ────────────────────────────────────────────────────────────
data "aws_iam_policy_document" "airflow_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "${local.oidc_provider_id}:sub"
values = ["system:serviceaccount:airflow:airflow"] # namespace:serviceaccount
}
condition {
test = "StringEquals"
variable = "${local.oidc_provider_id}:aud"
values = ["sts.amazonaws.com"]
}
}
}
resource "aws_iam_role" "airflow" {
name = "${var.environment}-airflow-irsa"
assume_role_policy = data.aws_iam_policy_document.airflow_assume.json
tags = local.common_tags
}
data "aws_iam_policy_document" "airflow_s3" {
statement {
effect = "Allow"
actions = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
resources = [
var.airflow_logs_bucket_arn,
"${var.airflow_logs_bucket_arn}/*",
]
}
statement {
effect = "Allow"
actions = ["s3:GetObject", "s3:ListBucket"]
resources = [var.dags_bucket_arn, "${var.dags_bucket_arn}/*"]
}
}
resource "aws_iam_role_policy" "airflow_s3" {
name = "airflow-s3"
role = aws_iam_role.airflow.id
policy = data.aws_iam_policy_document.airflow_s3.json
}
# ─── Spark IRSA ──────────────────────────────────────────────────────────────
data "aws_iam_policy_document" "spark_assume_irsa" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "${local.oidc_provider_id}:sub"
values = ["system:serviceaccount:spark-jobs:spark"]
}
condition {
test = "StringEquals"
variable = "${local.oidc_provider_id}:aud"
values = ["sts.amazonaws.com"]
}
}
}
resource "aws_iam_role" "spark_irsa" {
name = "${var.environment}-spark-irsa"
assume_role_policy = data.aws_iam_policy_document.spark_assume_irsa.json
tags = local.common_tags
}
resource "aws_iam_role_policy" "spark_lake" {
name = "spark-lake-access"
role = aws_iam_role.spark_irsa.id
policy = data.aws_iam_policy_document.spark_s3.json # reuse policy from above
}
# Annotate the K8s ServiceAccount so the pod inherits the role
resource "kubernetes_service_account" "spark" {
metadata {
name = "spark"
namespace = kubernetes_namespace.spark_jobs.metadata[0].name
annotations = {
"eks.amazonaws.com/role-arn" = aws_iam_role.spark_irsa.arn
}
}
}
MSK / Kafka
modules/kafka/main.tf — Amazon MSK
variable "kafka_version" { default = "3.7.x.kraft" }
variable "broker_count" { default = 3 }
variable "broker_instance_type" { default = "kafka.m5.2xlarge" }
variable "broker_volume_size" { default = 1000 } # GiB per broker
variable "kms_key_arn" { default = "" }
locals {
use_kms = var.kms_key_arn != ""
}
resource "aws_msk_configuration" "this" {
name = "${var.environment}-broker-config"
kafka_versions = [var.kafka_version]
server_properties = <<-EOT
auto.create.topics.enable=false
default.replication.factor=3
min.insync.replicas=2
num.partitions=6
log.retention.hours=168
log.retention.bytes=107374182400
log.segment.bytes=1073741824
compression.type=producer
message.max.bytes=10485760
replica.lag.time.max.ms=30000
unclean.leader.election.enable=false
offsets.topic.replication.factor=3
transaction.state.log.replication.factor=3
transaction.state.log.min.isr=2
EOT
}
resource "aws_security_group" "msk" {
name = "${var.environment}-msk-sg"
description = "MSK broker security group"
vpc_id = var.vpc_id
ingress {
description = "Kafka TLS from data workload SG"
from_port = 9094
to_port = 9094
protocol = "tcp"
security_groups = [var.data_workload_sg_id]
}
ingress {
description = "Kafka IAM from data workload SG"
from_port = 9098
to_port = 9098
protocol = "tcp"
security_groups = [var.data_workload_sg_id]
}
ingress {
description = "Zookeeper (legacy) — restricted"
from_port = 2181
to_port = 2181
protocol = "tcp"
security_groups = [var.data_workload_sg_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_msk_cluster" "this" {
cluster_name = "${var.environment}-kafka"
kafka_version = var.kafka_version
number_of_broker_nodes = var.broker_count
broker_node_group_info {
instance_type = var.broker_instance_type
client_subnets = var.private_subnet_ids # one per AZ; length must equal broker_count
storage_info {
ebs_storage_info {
volume_size = var.broker_volume_size
provisioned_throughput {
enabled = true
volume_throughput = 250 # MiB/s per broker
}
}
}
security_groups = [aws_security_group.msk.id]
}
configuration_info {
arn = aws_msk_configuration.this.arn
revision = aws_msk_configuration.this.latest_revision
}
encryption_info {
encryption_at_rest_kms_key_arn = local.use_kms ? var.kms_key_arn : null
encryption_in_transit {
client_broker = "TLS" # TLS | TLS_PLAINTEXT | PLAINTEXT
in_cluster = true
}
}
client_authentication {
sasl {
iam = true # IAM authentication for clients — no creds to rotate
scram = false
}
tls {
certificate_authority_arns = []
}
unauthenticated = false
}
open_monitoring {
prometheus {
jmx_exporter { enabled_in_broker = true }
node_exporter { enabled_in_broker = true }
}
}
logging_config {
broker_logs {
cloudwatch_logs {
enabled = true
log_group = aws_cloudwatch_log_group.msk.name
}
s3 {
enabled = true
bucket = var.logs_bucket_id
prefix = "msk/${var.environment}/broker-logs/"
}
}
}
tags = local.common_tags
}
resource "aws_cloudwatch_log_group" "msk" {
name = "/aws/msk/${var.environment}"
retention_in_days = 30
}
Outputs
output "bootstrap_brokers_tls" {
value = aws_msk_cluster.this.bootstrap_brokers_tls
}
output "bootstrap_brokers_sasl_iam" {
value = aws_msk_cluster.this.bootstrap_brokers_sasl_iam
}
output "zookeeper_connect_string" {
value = aws_msk_cluster.this.zookeeper_connect_string
sensitive = true
}
Confluent Cloud Alternative
terraform {
required_providers {
confluent = {
source = "confluentinc/confluent"
version = "~> 2.11"
}
}
}
provider "confluent" {
cloud_api_key = var.confluent_api_key
cloud_api_secret = var.confluent_api_secret
}
resource "confluent_environment" "data" {
display_name = "${var.environment}-data"
stream_governance { package = "ESSENTIALS" }
}
resource "confluent_kafka_cluster" "dedicated" {
display_name = "${var.environment}-kafka"
availability = "MULTI_ZONE"
cloud = "AWS"
region = var.region
dedicated { cku = 2 } # Confluent Kafka Units — scale as needed
environment { id = confluent_environment.data.id }
}
resource "confluent_kafka_topic" "events" {
kafka_cluster { id = confluent_kafka_cluster.dedicated.id }
topic_name = "raw.events"
partitions_count = 12
rest_endpoint = confluent_kafka_cluster.dedicated.rest_endpoint
config = {
"cleanup.policy" = "delete"
"retention.ms" = "604800000" # 7 days
"min.insync.replicas" = "2"
}
credentials {
key = var.kafka_api_key
secret = var.kafka_api_secret
}
}
Kubernetes Data Platform
modules/k8s-data/main.tf
terraform {
required_providers {
kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.31" }
helm = { source = "hashicorp/helm", version = "~> 2.14" }
}
}
# ─── Namespaces ──────────────────────────────────────────────────────────────
resource "kubernetes_namespace" "airflow" {
metadata {
name = "airflow"
labels = { "app.kubernetes.io/managed-by" = "terraform" }
}
}
resource "kubernetes_namespace" "spark_jobs" {
metadata {
name = "spark-jobs"
labels = { "app.kubernetes.io/managed-by" = "terraform" }
}
}
# ─── Resource Quotas ─────────────────────────────────────────────────────────
resource "kubernetes_resource_quota" "spark" {
metadata {
name = "spark-quota"
namespace = kubernetes_namespace.spark_jobs.metadata[0].name
}
spec {
hard = {
"requests.cpu" = "80"
"requests.memory" = "320Gi"
"limits.cpu" = "120"
"limits.memory" = "480Gi"
"pods" = "100"
}
}
}
resource "kubernetes_resource_quota" "airflow" {
metadata {
name = "airflow-quota"
namespace = kubernetes_namespace.airflow.metadata[0].name
}
spec {
hard = {
"requests.cpu" = "20"
"requests.memory" = "40Gi"
"limits.cpu" = "40"
"limits.memory" = "80Gi"
"pods" = "50"
}
}
}
# ─── Airflow Helm Release ─────────────────────────────────────────────────────
resource "helm_release" "airflow" {
name = "airflow"
repository = "https://airflow.apache.org"
chart = "airflow"
version = var.airflow_chart_version # pin for reproducibility
namespace = kubernetes_namespace.airflow.metadata[0].name
create_namespace = false
atomic = true # roll back if install fails
cleanup_on_fail = true
timeout = 600
values = [
templatefile("${path.module}/templates/airflow-values.yaml.tpl", {
environment = var.environment
airflow_image_tag = var.airflow_image_tag
airflow_irsa_role_arn = var.airflow_irsa_role_arn
db_host = var.airflow_db_host
dags_repo = var.dags_git_repo
log_bucket = var.airflow_logs_bucket_id
})
]
set_sensitive {
name = "data.metadataConnection.pass"
value = var.airflow_db_password
}
set_sensitive {
name = "fernetKey"
value = var.airflow_fernet_key
}
depends_on = [kubernetes_resource_quota.airflow]
}
# ─── Spark History Server Helm Release ────────────────────────────────────────
resource "helm_release" "spark_history_server" {
name = "spark-history-server"
repository = "https://charts.helm.sh/stable"
chart = "spark-history-server"
version = var.spark_history_chart_version
namespace = kubernetes_namespace.spark_jobs.metadata[0].name
create_namespace = false
set {
name = "hdfs.logDirectory"
value = "s3a://${var.lake_bucket_id}/spark-history"
}
set {
name = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
value = var.spark_irsa_role_arn
}
set {
name = "resources.requests.cpu"
value = "500m"
}
set {
name = "resources.requests.memory"
value = "1Gi"
}
depends_on = [kubernetes_resource_quota.spark]
}
# ─── Node Taints for Data Workloads (EKS Managed Node Group) ─────────────────
# Declared separately per-environment via aws_eks_node_group, shown here as reference:
# taint { key = "dedicated", value = "spark", effect = "NO_SCHEDULE" }
# taint { key = "dedicated", value = "airflow", effect = "NO_SCHEDULE" }
modules/k8s-data/templates/airflow-values.yaml.tpl
executor: KubernetesExecutor
images:
airflow:
repository: "registry.example.com/airflow"
tag: "${airflow_image_tag}"
pullPolicy: IfNotPresent
config:
core:
dags_are_paused_at_creation: "True"
max_active_runs_per_dag: "5"
logging:
remote_logging: "True"
remote_base_log_folder: "s3://${log_bucket}/airflow-logs"
remote_log_conn_id: "aws_default"
scheduler:
replicas: 2
dags:
gitSync:
enabled: true
repo: "${dags_repo}"
branch: main
depth: 1
serviceAccount:
create: true
name: airflow
annotations:
eks.amazonaws.com/role-arn: "${airflow_irsa_role_arn}"
data:
metadataConnection:
user: airflow
host: "${db_host}"
port: 5432
db: airflow
protocol: postgresql
redis:
enabled: false
triggerer:
enabled: true
Variables, Outputs, and Locals
Typed Variables with Validation
# envs/prod/variables.tf
variable "region" {
type = string
description = "AWS region for all resources."
default = "us-east-1"
validation {
condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]$", var.region))
error_message = "Must be a valid AWS region slug (e.g. us-east-1)."
}
}
variable "environment" {
type = string
default = "prod"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
variable "airflow_db_password" {
type = string
description = "Password for the Airflow metadata database."
sensitive = true # redacted from plan output and state logs
}
variable "airflow_fernet_key" {
type = string
sensitive = true
}
variable "kafka_broker_count" {
type = number
validation {
condition = var.kafka_broker_count >= 3 && var.kafka_broker_count % 3 == 0
error_message = "Broker count must be a multiple of 3 (one per AZ) and >= 3."
}
}
variable "eks_node_groups" {
type = map(object({
instance_types = list(string)
min_size = number
max_size = number
desired_size = number
taint_value = optional(string, "")
}))
description = "Map of EKS managed node group configurations."
}
terraform.tfvars (prod)
# envs/prod/terraform.tfvars
region = "us-east-1"
environment = "prod"
kafka_broker_count = 3
eks_node_groups = {
spark-workers = {
instance_types = ["r6i.4xlarge"]
min_size = 0
max_size = 20
desired_size = 3
taint_value = "spark"
}
airflow-workers = {
instance_types = ["m6i.2xlarge"]
min_size = 2
max_size = 10
desired_size = 3
taint_value = "airflow"
}
system = {
instance_types = ["m6i.xlarge"]
min_size = 2
max_size = 5
desired_size = 2
}
}
Locals for Computed Values
locals {
name_prefix = "${var.environment}-${var.project}"
# bucket names derived from environment — avoid duplication across modules
bronze_bucket = "${local.name_prefix}-bronze"
silver_bucket = "${local.name_prefix}-silver"
gold_bucket = "${local.name_prefix}-gold"
# all data workload roles — used in bucket policy
data_role_arns = [
module.iam_spark.role_arn,
module.iam_airflow.role_arn,
]
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
CostCenter = var.cost_center
}
}
Cross-Module Outputs
# envs/prod/main.tf — wiring modules together via outputs
module "s3_bronze" {
source = "../../modules/s3-lake"
bucket_name = local.bronze_bucket
environment = var.environment
kms_key_arn = module.kms.key_arn
allowed_role_arns = local.data_role_arns
}
module "kafka" {
source = "../../modules/kafka"
environment = var.environment
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnets
data_workload_sg_id = module.eks.worker_security_group_id
logs_bucket_id = module.s3_bronze.bucket_id
kms_key_arn = module.kms.key_arn
broker_count = var.kafka_broker_count
}
module "k8s_data" {
source = "../../modules/k8s-data"
environment = var.environment
airflow_irsa_role_arn = module.iam_airflow.irsa_role_arn
spark_irsa_role_arn = module.iam_spark.irsa_role_arn
lake_bucket_id = module.s3_bronze.bucket_id
airflow_logs_bucket_id = module.s3_bronze.bucket_id
airflow_db_host = module.rds_airflow.endpoint
airflow_db_password = var.airflow_db_password
airflow_fernet_key = var.airflow_fernet_key
dags_git_repo = "https://github.com/org/airflow-dags.git"
}
State Management and CI/CD
Terraform Cloud Backend (Alternative to S3)
# envs/prod/backend.tf — Terraform Cloud
terraform {
cloud {
organization = "my-company"
workspaces {
name = "data-platform-prod"
}
}
}
GitHub Actions CI/CD Pipeline
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
permissions:
id-token: write # OIDC token for AWS AssumeRoleWithWebIdentity
contents: read
pull-requests: write
env:
TF_VERSION: "1.9.8"
AWS_REGION: "us-east-1"
WORKING_DIR: "infra/envs/prod"
jobs:
plan:
name: Terraform Plan
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
defaults:
run:
working-directory: ${{ env.WORKING_DIR }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-tf-plan
aws-region: ${{ env.AWS_REGION }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init -input=false
- name: Terraform Validate
run: terraform validate
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Plan
id: plan
run: |
terraform plan \
-var-file="terraform.tfvars" \
-var="airflow_db_password=${{ secrets.AIRFLOW_DB_PASSWORD }}" \
-var="airflow_fernet_key=${{ secrets.AIRFLOW_FERNET_KEY }}" \
-input=false \
-out=tfplan \
-no-color 2>&1 | tee plan.txt
echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Comment Plan on PR
uses: actions/github-script@v7
if: always()
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('${{ env.WORKING_DIR }}/plan.txt', 'utf8');
const truncated = plan.length > 60000 ? plan.slice(-60000) + '\n[truncated]' : plan;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Terraform Plan\n\`\`\`hcl\n${truncated}\n\`\`\``
});
apply:
name: Terraform Apply
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # requires manual approval gate in GitHub
defaults:
run:
working-directory: ${{ env.WORKING_DIR }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-tf-apply
aws-region: ${{ env.AWS_REGION }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init -input=false
- name: Terraform Apply
run: |
terraform apply \
-var-file="terraform.tfvars" \
-var="airflow_db_password=${{ secrets.AIRFLOW_DB_PASSWORD }}" \
-var="airflow_fernet_key=${{ secrets.AIRFLOW_FERNET_KEY }}" \
-auto-approve \
-input=false
drift:
name: Drift Detection
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
# schedule: cron: '0 6 * * 1-5' # run in calling workflow
defaults:
run:
working-directory: ${{ env.WORKING_DIR }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-tf-plan
aws-region: ${{ env.AWS_REGION }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- run: terraform init -input=false
- name: Detect Drift
run: |
terraform plan \
-var-file="terraform.tfvars" \
-var="airflow_db_password=${{ secrets.AIRFLOW_DB_PASSWORD }}" \
-var="airflow_fernet_key=${{ secrets.AIRFLOW_FERNET_KEY }}" \
-detailed-exitcode \
-input=false || \
(echo "DRIFT DETECTED — manual review required" && exit 1)
Terragrunt — DRY Multi-Environment Configs
Terragrunt eliminates per-environment backend.tf and provider.tf duplication. One root terragrunt.hcl defines the S3 backend pattern; child configs just declare their module source and inputs.
# terragrunt/terragrunt.hcl (root)
locals {
account_id = get_aws_account_id()
region = "us-east-1"
environment = basename(dirname(get_terragrunt_dir())) # dev / prod
}
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "my-company-tf-state-${local.account_id}"
key = "${local.environment}/${path_relative_to_include()}/terraform.tfstate"
region = local.region
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<-EOF
provider "aws" {
region = "${local.region}"
default_tags {
tags = {
Environment = "${local.environment}"
ManagedBy = "terraform"
}
}
}
EOF
}
# terragrunt/prod/s3-lake/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../../modules/s3-lake"
}
inputs = {
environment = "prod"
bucket_name = "my-company-prod-bronze"
kms_key_arn = dependency.kms.outputs.key_arn
glacier_transition_days = 90
expiration_days = 730
allowed_role_arns = dependency.iam.outputs.data_role_arns
}
dependency "kms" {
config_path = "../kms"
mock_outputs = { key_arn = "arn:aws:kms:us-east-1:123456789012:key/mock" }
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
dependency "iam" {
config_path = "../iam"
mock_outputs = { data_role_arns = [] }
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
Deploy all prod modules in dependency order:
cd terragrunt/prod
terragrunt run-all plan # shows plan for all modules
terragrunt run-all apply # applies in DAG order
Anti-Patterns
-
Storing
terraform.tfstatein Git — state contains plaintext secrets (DB passwords, private keys). Always use a remote backend (S3 + DynamoDB or Terraform Cloud). Add*.tfstateand*.tfstate.backupto.gitignore. -
Hardcoding secrets in
terraform.tfvars—terraform.tfvarsis committed to Git. Pass sensitive variables via environment variables (TF_VAR_airflow_db_password) or a secrets manager integration, never as plaintext in tracked files. -
No
sensitive = trueon secret variables — without it, Terraform prints secret values in plan output and logs. Mark every password, token, and key variable withsensitive = true. -
Using the default AWS provider
regionwithout explicit pinning — a developer with a different CLI default region can silently deploy to the wrong region. Always setregionexplicitly in the provider block or via a required variable. -
Sharing one state file across environments — a bad
prodapply destroys dev resources and vice versa. Keep per-environment state files with separate keys (and separate AWS accounts for critical workloads). -
No DynamoDB lock table — two concurrent
terraform applyruns will corrupt state. The lock table is cheap (PAY_PER_REQUEST) and must always accompany an S3 backend. -
Pinning
~> awsprovider without a minor-version floor —~> 5.0allows 5.x patches but a major jump from 4.x→5.x is breaking. Use~> 5.60(current major + recent minor) to allow patches while preventing accidental major upgrades. -
force_destroy = trueon production S3 buckets — oneterraform destroypermanently deletes all data. Setforce_destroy = falsefor prod; use separate out-of-band deletion procedures. -
Creating MSK clusters without
min.insync.replicas=2— with the default of 1, a single broker failure silently allows producers to commit messages that are never replicated, causing data loss on broker restart. -
Running Terraform Apply directly in CI without a plan approval gate — auto-approve on push to main can silently destroy production resources. Use a GitHub environment protection rule requiring manual approval before
apply, or use Terraform Cloud's Run Queue with policy checks. -
Not tagging resources with
environmentandManagedBy— untagged resources become orphans after state drift. Use adefault_tagsblock in the AWS provider (orlocal.common_tags) applied to every resource. -
One giant
main.tfper environment instead of modules — copy-pasting infrastructure blocks betweenenvs/devandenvs/prodcreates drift. Extract every reusable component into a module immediately.
References to Consult When Needed
- Terraform AWS provider docs:
registry.terraform.io/providers/hashicorp/aws/latest/docs aws_msk_clusterresource:registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/msk_cluster- terraform-aws-modules/iam IRSA submodule:
registry.terraform.io/modules/terraform-aws-modules/iam/aws/latest/submodules/iam-role-for-service-accounts - Terragrunt docs:
terragrunt.gruntwork.io/docs/ - Terraform S3 backend:
developer.hashicorp.com/terraform/language/settings/backends/s3 - MinIO Terraform provider:
registry.terraform.io/providers/aminueza/minio/latest - Confluent provider:
registry.terraform.io/providers/confluentinc/confluent/latest - AWS prescriptive guidance — Terraform best practices:
docs.aws.amazon.com/prescriptive-guidance/latest/terraform-aws-provider-best-practices/
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.