Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 082 / 100

Infrastructure as code anti-patterns in Terraform & IaC

Mistakes that hurt teams.

Expona Admin8 min read

Infrastructure as code anti-patterns in Terraform & IaC

After a year of managing infrastructure through code, most teams have learned painful lessons the hard way. We've seen production outages triggered by a single typo in a tfvars file, databases locked into tight coupling that made refactoring nightmare, and entire environments rebuilt from scratch because the state file went missing. The difference between a mature infrastructure-as-code practice and one that crumbles under pressure often comes down to recognizing and avoiding common anti-patterns early.

The One Giant State File Problem

Building everything into a single Terraform state is the path of least resistance. When you're starting out, it feels efficient—one command, one state, everything deployed. But as your infrastructure grows, this becomes a critical liability.

A typical scenario: your team manages networking, databases, Kubernetes clusters, and application infrastructure all in one state file. Someone needs to add a security group rule while another engineer is updating the cluster version. Now you're coordinating state locks, dealing with merge conflicts, and risking accidental deletions of unrelated resources. One stale cached state file, one interrupted terraform apply, and your entire production infrastructure is at risk.

The anti-pattern manifests in two ways. First, the organizational anti-pattern: all code in one repository, one state file. Second, the structural anti-pattern: everything defined in root-level Terraform files instead of modules.

hcl
# Anti-pattern: giant monolithic main.tf
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_eks_cluster" "main" {
  name = "production"
  # ... 50 more lines
}

resource "aws_rds_cluster" "databases" {
  cluster_identifier = "prod-db"
  # ... configuration
}

resource "aws_lb" "api" {
  name = "api-lb"
  # ... configuration
}

# And 200 more resources...

Instead, separate concerns into logical modules and backends. Use workspace isolation for environment differences or, better yet, separate state backends entirely.

hcl
# Better: structured modules with separate backends
# terraform/networking/main.tf
module "vpc" {
  source = "./modules/vpc"
  cidr_block = var.cidr_block
}

# terraform/databases/main.tf
module "rds" {
  source = "./modules/rds"
  vpc_id = data.terraform_remote_state.networking.outputs.vpc_id
}

# terraform/kubernetes/main.tf
module "eks" {
  source = "./modules/eks"
  vpc_id = data.terraform_remote_state.networking.outputs.vpc_id
}

Each module gets its own state backend, often backed by different S3 buckets with appropriate lock mechanisms. This approach scales: teams can own specific domains without stepping on each other's toes.

Hard-Coded Values Scattered Throughout

Hard-coding configuration values into your Terraform directly defeats much of the point of infrastructure-as-code. It makes your code inflexible, environment-specific, and difficult to reuse. Yet we see it constantly: database instance types, node counts, domain names, all embedded in resource definitions.

The problem compounds across environments. Your development cluster runs on 2 t3.small nodes, but production needs 10 c5.xlarge instances. Rather than using variables, you maintain separate Terraform code for each environment—or worse, you use the same code and manually override things in the console (which we'll address next).

hcl
# Anti-pattern: hard-coded values
resource "aws_eks_node_group" "workers" {
  cluster_name = aws_eks_cluster.main.name
  node_role_arn = aws_iam_role.worker.arn
  subnet_ids = ["subnet-12345", "subnet-67890"]
  
  scaling_config {
    desired_size = 10
    max_size = 15
    min_size = 5
  }
  
  instance_types = ["c5.xlarge"]
}

This code works only for one specific deployment. To use it elsewhere, someone copies it and tweaks the values manually—or worse, they edit it in the console after creation.

hcl
# Better: externalize configuration
variable "node_count" {
  type = number
  description = "Desired number of worker nodes"
}

variable "instance_type" {
  type = string
  description = "EC2 instance type for workers"
  default = "t3.medium"
}

resource "aws_eks_node_group" "workers" {
  cluster_name = aws_eks_cluster.main.name
  node_role_arn = aws_iam_role.worker.arn
  subnet_ids = var.subnet_ids
  
  scaling_config {
    desired_size = var.node_count
    max_size = var.node_count + 5
    min_size = var.node_count - 2
  }
  
  instance_types = [var.instance_type]
}

Pair this with environment-specific tfvars files (holding non-sensitive values only) or a proper configuration management layer. This becomes your single source of truth for what infrastructure should look like in each environment.

Making Changes in the Console Instead of Code

This is where theoretical infrastructure-as-code breaks down in practice. Someone logs into the AWS console to "quickly" add a security group rule, adjust an RDS parameter, or scale up a load balancer. It works immediately and feels faster than waiting for a code review and terraform apply. So it happens again. And again.

Within months, your actual infrastructure has drifted significantly from what Terraform describes. The next time someone runs terraform plan, they see hundreds of "changes" that don't represent intentional modifications—they're just catching up to reality. At this point, team members lose trust in Terraform. They stop using it and manage more things manually.

This drift becomes catastrophic during incidents. You're trying to debug a production issue, but you can't trust whether the current configuration matches what's in version control. A teammate spins up infrastructure to replicate the issue, but it fails because they're working from the code, not from the actual console state.

The fix is organizational and technical. Organizationally, establish a hard rule: all infrastructure changes go through code. Make it easier to change Terraform than to log into the console. Technically, use tools like Terraform Cloud or policy-as-code frameworks to prevent direct console modifications on production resources. Add IAM policies that restrict manual changes.

hcl
# Example: IAM policy preventing direct modifications
# Allow describe/list operations but deny direct resource changes
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:Describe*",
        "rds:Describe*"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Deny",
      "Action": [
        "ec2:ModifySecurityGroup*",
        "ec2:AuthorizeSecurityGroup*",
        "rds:ModifyDBInstance",
        "elasticloadbalancing:ModifyLoadBalancer*"
      ],
      "Resource": "*"
    }
  ]
}

Also run regular drift detection. Terraform can generate a plan against actual infrastructure and alert you to any divergence. This makes manual changes visible and creates pressure to bring code back in sync.

Storing Secrets in tfvars Files

This is perhaps the most dangerous anti-pattern. Database passwords, API keys, OAuth tokens—all checked into Git inside tfvars files. Even if the repository is private, even if access is controlled, this practice exposes critical secrets to anyone with repository access and creates an audit trail that can never be fully erased.

We've seen production credentials discovered by supply-chain attacks, ex-employees retaining access, and security researchers finding them in public GitHub mirrors. The damage compounds because secrets often have long lifespans; rotating them requires coordinated effort across services.

hcl
# ANTI-PATTERN: Never do this
# terraform.tfvars
db_password = "MyP@ssw0rd123"
api_key = "sk-abc123def456"
oauth_token = "ghp_1234567890abcdefgh"

Use a proper secrets management system instead. AWS Secrets Manager, HashiCorp Vault, or even AWS Systems Manager Parameter Store work well. Your Terraform code retrieves secrets at runtime without storing them in files.

hcl
# Better: retrieve secrets from AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db_credentials" {
  secret_id = "prod/rds/credentials"
}

locals {
  db_password = jsondecode(data.aws_secretsmanager_secret_version.db_credentials.secret_string)["password"]
}

resource "aws_db_instance" "main" {
  identifier = "prod-database"
  password = local.db_password
  # ... rest of configuration
}

# Or using Parameter Store
data "aws_ssm_parameter" "api_key" {
  name = "/prod/api/key"
  with_decryption = true
}

output "api_key" {
  value = data.aws_ssm_parameter.api_key.value
  sensitive = true
}

Mark outputs containing secrets as sensitive so Terraform won't log them to console or state. Rotate secrets regularly and audit access. Use IAM roles so EC2 instances and Kubernetes pods can retrieve secrets without embedding credentials anywhere.

Common Mistakes

  • Not using remote state backends: Storing state locally on developer machines means different team members have different state versions. Use S3 with DynamoDB locking, or Terraform Cloud, from day one.
  • Ignoring state file encryption: Even with remote backends, state files contain sensitive information. Enable encryption at rest and in transit. Restrict access via IAM policies.
  • No code review process: Terraform changes should go through pull requests like any other code. Use policy-as-code tools like Sentinel or OPA to enforce standards automatically.
  • Mixing infrastructure and application deployment: Use Terraform for infrastructure, but deploy applications via CI/CD pipelines and configuration management. Avoid embedding application code or versioning in Terraform.
  • Ignoring documentation: Self-documenting code helps, but complex deployments need explicit documentation. Include architecture diagrams, environment-specific notes, and runbooks for common operations.
  • No tagging strategy: Resources without consistent tags make cost allocation, compliance auditing, and cleanup difficult. Define a tagging standard and enforce it via policy.

Key Takeaways

  • Split monolithic state into logical modules with separate backends; let teams own infrastructure domains without coordination overhead.
  • Externalize all configuration values into variables and environment-specific tfvars files; hard-coded values guarantee inflexibility and drift.
  • Enforce code-driven infrastructure changes through IAM policies and organizational discipline; console access should be read-only for troubleshooting.
  • Never store secrets in version control; use AWS Secrets Manager, Vault, or Parameter Store and mark sensitive outputs accordingly.
  • Implement remote state with locking, encryption, and access controls; establish code review workflows and policy-as-code enforcement from the start.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles