Infrastructure as Code (IaC) lets you define servers, networks, databases, and security controls in version-controlled configuration files. This is a security advantage — infrastructure becomes auditable, repeatable, and reviewable. It is also a risk — a misconfiguration in a template can expose your entire cloud environment.
Why IaC matters for security
Before IaC, infrastructure was configured manually through cloud consoles. This meant:
- No audit trail of who changed what
- No code review before changes went live
- Inconsistency between environments (staging and production drifted)
- Security configurations that were forgotten or overridden
IaC solves these problems by treating infrastructure like application code: written, reviewed, tested, and deployed through the same CI/CD pipeline.
Common IaC tools
| Tool | Language | Cloud support |
|---|---|---|
| Terraform / OpenTofu | HCL | Multi-cloud (AWS, GCP, Azure, etc.) |
| AWS CloudFormation | JSON/YAML | AWS only |
| Pulumi | TypeScript, Python, Go, C# | Multi-cloud |
| AWS CDK | TypeScript, Python, Java, C# | AWS only (generates CloudFormation) |
| Azure Bicep | Bicep DSL | Azure only |
| Google Cloud Deployment Manager | YAML/Jinja2 | GCP only |
The security principles are the same regardless of the tool.
Common security misconfigurations
Public S3 buckets
The most common and most publicised IaC mistake. A single acl = "public-read" makes every object in the bucket accessible to the internet:
# VULNERABLE
resource "aws_s3_bucket_acl" "data" {
bucket = aws_s3_bucket.data.id
acl = "public-read"
}
# SECURE — block all public access
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Overly permissive security groups
A security group that allows 0.0.0.0/0 on all ports is essentially no firewall:
# VULNERABLE — open to the internet
resource "aws_security_group_rule" "allow_all" {
type = "ingress"
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# SECURE — restrict to specific ports and sources
resource "aws_security_group_rule" "allow_https" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"] # Internal network only
}
IAM wildcards
Wildcard permissions grant access to everything:
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
This is the cloud equivalent of running everything as root. Use specific actions and resources:
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*"
}
Unencrypted resources
Databases, storage, and queues should be encrypted at rest:
resource "aws_db_instance" "main" {
storage_encrypted = true
# ...
}
resource "aws_sqs_queue" "orders" {
kms_master_key_id = aws_kms_key.main.id
}
Logging disabled
CloudTrail, VPC Flow Logs, and access logging should be enabled by default:
resource "aws_cloudtrail" "main" {
name = "main-trail"
s3_bucket_name = aws_s3_bucket.logs.id
is_multi_region_trail = true
enable_logging = true
}
Scanning IaC for misconfigurations
Static analysis tools catch security issues before deployment:
# Checkov (supports Terraform, CloudFormation, Kubernetes, Dockerfiles)
checkov -d .
# tfsec (Terraform-specific)
tfsec .
# KICS (Keeping Infrastructure as Code Secure)
kics scan -p .
Integrate these into your CI pipeline:
- name: Scan Terraform
run: checkov -d infrastructure/ --framework terraform --soft-fail-on LOW
Policy as code
For custom rules, use tools like Open Policy Agent (OPA) or Sentinel (HashiCorp):
# OPA policy — deny public S3 buckets
deny[msg] {
resource := input.resource.aws_s3_bucket[name]
resource.acl == "public-read"
msg := sprintf("S3 bucket '%s' must not be public", [name])
}
State file security
Terraform and similar tools maintain a state file that maps your configuration to real cloud resources. The state file contains:
- Resource IDs and configurations
- Output values (which may include secrets)
- Metadata about your infrastructure
Protect the state file
- Never commit state files to Git. They often contain secrets.
- Use remote state backends with encryption and access control (S3 + DynamoDB locking, GCS, Terraform Cloud).
- Enable state file encryption at rest.
- Restrict access to the state backend. Only the CI pipeline and authorised operators should have access.
terraform {
backend "s3" {
bucket = "myapp-terraform-state"
key = "prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-lock"
}
}
Drift detection
Over time, manual changes in the cloud console cause the actual infrastructure to drift from the IaC definition. This drift can introduce security misconfigurations that your IaC scans will not catch (because the misconfiguration is not in the code).
Detect drift regularly:
terraform plan # Shows differences between state and actual infrastructure
Automate drift detection and alert when changes are detected. Then fix the drift by either updating the IaC or reverting the manual change.
Modules and reuse
Encapsulate security best practices in reusable modules:
module "secure_bucket" {
source = "./modules/secure-s3-bucket"
bucket_name = "my-data"
}
The module enforces encryption, public access blocks, logging, and versioning. Teams use the module instead of configuring S3 from scratch, ensuring consistent security.
Summary
IaC makes infrastructure auditable and repeatable, but it also codifies misconfigurations. The most common mistakes — public storage, permissive security groups, wildcard IAM, unencrypted resources, and disabled logging — can all be caught by scanning tools like Checkov and tfsec. Run them in CI on every pull request. Protect state files with remote backends and encryption. Detect drift regularly. Encapsulate security best practices in reusable modules. The goal is infrastructure that is secure by default, not secure by vigilance.
