💡 Introduction
Welcome, Devs 👋 to the exciting world of Infrastructure as Code (IaC) and automation!
If you’ve been working with Terraform, you already know how powerful it is in spinning up infrastructure in minutes. But with great power comes… well, the need for great security.
In today’s cloud-first job market, security isn’t optional—it’s a core skill. DevOps engineers are now expected to think like DevSecOps engineers, ensuring that every piece of code, every module, and every configuration follows security best practices. Why? Because misconfigured infrastructure is like leaving your front door unlocked—hackers love it.
That’s exactly what we’re diving into today. In this blog, I’ll walk you through Terraform security best practices that will help you safeguard your cloud environments, avoid costly mistakes, and step confidently into the world of DevSecOps.
So, grab your coffee ☕ and let’s get started! 🚀
1. Verify Modules and Providers 🔍
When working with Terraform, your providers and modules are just like external dependencies in application development. And just like you wouldn’t blindly install a random library in your production code, you shouldn’t blindly trust Terraform modules or providers either.
Think of it this way:
Providers are your bridge to cloud platforms (AWS, Azure, GCP, etc.).
Modules are reusable building blocks that make your Terraform code cleaner and more scalable.
But since both come from external sources, treating them with security-first practices is a must.
✅ Always Pin the Source and Version
Instead of using a vague provider declaration like this:
provider "aws" {
region = "us-east-1"
}
Lock it down with a clear source and version:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.98.0"
}
}
}
This ensures you’re not pulling in an unverified or malicious provider update without realizing it. Think of it as “freezing” dependencies in your app code—predictability and security go hand in hand.
🏢 For Organizations
If you’re working in a team or an enterprise environment, you’ll likely have a private registry in place. That’s a great way to enforce version control and ensure everyone only uses approved and vetted providers/modules.
Some best practices here:
Use a filesystem or network mirror to point to an internal artifact repo (similar to how you store container images).
Implement a minimal module registry API so teams can share trusted Terraform modules across projects.
Review and approve new modules/providers before they go into production pipelines.
In short: treat Terraform providers and modules like you treat code libraries—verify, version, and control them.
2. Don’t Store the State File Locally 🔒
One of the most common mistakes Terraform beginners (and sometimes even pros) make is keeping the state file (terraform.tfstate
) on their local machine.
Why is this risky?
Your state file contains sensitive data like resource IDs, configuration details, and even secrets.
If stored locally or pushed to a public repo (e.g., GitHub), it’s basically like handing over your cloud blueprint to attackers.
A leaked state file can lead to account compromise or full infra takeover.
✅ Best Practice: Remote and Encrypted Storage
Instead of keeping state locally, store it in a secure, remote backend that your team and organization can access safely. Make sure it’s encrypted to protect against unauthorized access.
Here’s an example using AWS S3 with encryption and versioning:
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "my-terraform-state.tfstate"
region = "eu-west-1"
use_lockfile = true
encrypt = true
}
}
🔑 A Note on State Locking
Earlier, Terraform state locking was usually handled with S3 + DynamoDB. But with recent updates, S3 now has the use_lockfile
property, making it easier to prevent race conditions when multiple people or pipelines try to update the state at the same time.
🚀 Quick Checklist
Never commit state files to GitHub (or any VCS).
Always enable encryption and versioning in your remote backend.
Use role-based access control (RBAC) to restrict who can read/write to the state.
In short: treat your Terraform state like a password vault—it holds the keys to your entire infrastructure. 🔐
3. Detect Vulnerabilities Early 🕵️♂️
Security isn’t something you “add at the end.” With Terraform (and IaC in general), you want to shift security left—meaning you catch issues while writing code, not after deploying it to production.
That’s where static code analysis tools come in. These tools scan your Terraform configuration and flag potential misconfigurations or risky patterns before they ever touch your cloud environment.
🚨 What They Catch
Unintended resource exposure (e.g., open security groups, public S3 buckets).
Weak encryption setups (like missing
encrypt = true
).Misconfigurations that could lead to compliance violations.
🛠️ Popular Tools for Terraform Security
tfsec: Lightweight, easy-to-use, and integrates well with CI/CD pipelines.
Checkov: Great for policy-as-code, compliance checks, and multi-cloud environments.
Terrascan: One of the most popular tools, with 500+ built-in policies for Terraform, Kubernetes, and more.
▶️ Example: Scanning with Terrascan
Run this command to analyze your Terraform code:
terrascan scan -f /path/to/terraform/code
It will quickly point out vulnerabilities, risky resources, or compliance issues—saving you hours of debugging and reducing security risks before deployment.
👉 Pro tip: Integrate these scans into your CI/CD pipeline so every commit or PR is automatically checked for vulnerabilities. That way, you’re not just automating infra deployment—you’re automating secure infra deployment. 🚀
4. Apply the Principle of Least Privilege 🔑
When it comes to securing cloud infrastructure, one golden rule stands out: never give more access than necessary.
This is the Principle of Least Privilege (PoLP)—granting only the minimum level of permissions required for a resource or user to function. By following PoLP, you reduce the attack surface and limit potential damage in case of a breach.
Think of it like this: if your friend only needs to borrow your car keys, don’t hand over the keys to your house too.
✅ Why It Matters in Terraform
Terraform often interacts with your cloud provider (AWS, Azure, GCP) to spin up or manage resources. If the IAM roles or service accounts used by Terraform are too permissive, attackers can exploit that access to move laterally, exfiltrate data, or even shut down your infra.
📝 Example: Minimal Policy for Reading Terraform State from S3
Here’s a sample IAM policy for an EC2 instance that only needs to read the Terraform state file:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowListAndLocation",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::your-state-file-bucket"
},
{
"Sid": "AllowReadStateFile",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-state-file-bucket/path/to/your/terraform.tfstate"
}
]
}
Notice how this policy doesn’t allow write/delete actions—just enough access to list and read the state file.
🚀 Best Practices
Always scope IAM roles/policies to the specific bucket, object, or resource path.
Avoid using wildcards (
*
) unless absolutely necessary.Regularly audit IAM policies to check for over-privileged accounts.
Use tools like AWS IAM Access Analyzer to catch unintended permissions.
In short: lock the doors you don’t need to open. By sticking to PoLP, you make life harder for attackers and safer for your team.
5. Don’t Modify the Terraform State File Manually ⚠️
Your Terraform state file is the single source of truth for your infrastructure. It keeps track of all the resources Terraform manages. Because of that, manually editing the state file is a recipe for disaster.
Why?
It can corrupt the state, making Terraform lose track of your infra.
It introduces configuration drift—your code and your actual infra won’t match.
Worst case, it can lead to unexpected resource destruction (yep, Terraform might just wipe out live resources).
❌ The Wrong Way
Let’s say you have this configuration for an EC2 instance:
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "web_server" {
ami = "ami-0c55b159f2f12217c"
instance_type = "t2.micro"
}
Now you decide to rename web_server
to app_server
. You update the code like this:
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "app_server" {
ami = "ami-0c55b159f2f12217c"
instance_type = "t2.micro"
}
When you run terraform plan
, Terraform sees this as:
Delete:
aws_instance.web_server
Create:
aws_
instance.app
_server
Meaning—your EC2 instance is destroyed and replaced, causing unnecessary downtime.
✅ The Right Way
Instead of editing the state file or letting Terraform destroy and recreate resources, use the built-in state management command:
terraform state mv aws_instance.web_server aws_instance.app_server
This safely moves the resource address in the state file without touching the actual EC2 instance. Terraform will now recognize the resource under its new name, keeping your infra intact.
🚀 Best Practice Recap
Never open and edit
terraform.tfstate
directly.Always use Terraform CLI (
terraform state mv
,terraform state rm
,terraform import
, etc.) to manage resources.Commit your config changes along with proper state moves to avoid drift.
Remember: your state file is like the brain of Terraform—don’t perform surgery on it without the right tools. 🧠🔧
Awesome pointer 🙌 This is the heart of the blog—the hands-on demonstration that ties everything together. I’ll write it step by step in a practical, tutorial-like tone while reinforcing the 5 best practices we already covered. Here’s the draft:
6. Practical Demonstration 🛠️
Now that we’ve gone through the 5 Terraform security best practices, let’s put them into action. Nothing beats seeing these principles applied in a real-world example.
We’ll set up a simple Terraform configuration that:
Pins the provider source and version.
Stores the state file securely in S3 with encryption + locking.
Implements Principle of Least Privilege (PoLP) with IAM policies.
Demonstrates state management commands instead of manual edits.
Uses Terrascan to detect misconfigurations early.
Ready? Let’s dive in 🚀
To make things easy, I have hosted the code of this project on my Github account, just got the following repo:
Best-Practices project
Navigate inside the best-practices dir.
Step 1: Create main.tf
and Specify Provider
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.14.1"
}
}
}
provider "aws" {
region = "us-east-1"
}
Here, we’ve pinned the provider source and version (hashicorp/aws
at 6.14.1
) and specified the region. This ensures consistency and avoids pulling in unverified updates.
Step 2: Add an EC2 Instance with PoLP IAM Policy
Now let’s create an EC2 instance and enforce Principle of Least Privilege by attaching an IAM policy that allows it to only read the Terraform state file from S3.
# IAM Policy Document for S3 State Access
data "aws_iam_policy_document" "s3_state_access_doc" {
statement {
sid = "AllowListAndLocation"
effect = "Allow"
actions = [
"s3:ListBucket",
"s3:GetBucketLocation"
]
resources = ["arn:aws:s3:::my-pravesh-terraform-state-bucket-2025"]
}
statement {
sid = "AllowReadStateFile"
effect = "Allow"
actions = [
"s3:GetObject"
]
resources = ["arn:aws:s3:::my-pravesh-terraform-state-bucket-2025/terraform/terraform.tfstate"]
}
}
# Create the IAM Policy
resource "aws_iam_policy" "s3_state_access_policy" {
name = "EC2-S3-State-Read-Policy"
description = "Policy for EC2 to read the Terraform state file."
policy = data.aws_iam_policy_document.s3_state_access_doc.json
}
# Create the IAM Role
resource "aws_iam_role" "ec2_s3_role" {
name = "EC2-S3-State-Reader-Role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = {
Service = "ec2.amazonaws.com"
},
Sid = ""
},
],
})
}
# Attach Policy to Role
resource "aws_iam_role_policy_attachment" "s3_state_attach" {
role = aws_iam_role.ec2_s3_role.name
policy_arn = aws_iam_policy.s3_state_access_policy.arn
}
# Create the Instance Profile
resource "aws_iam_instance_profile" "ec2_s3_profile" {
name = "EC2-S3-State-Reader-Profile"
role = aws_iam_role.ec2_s3_role.name
}
# Get Latest Ubuntu AMI
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-*-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Create the EC2 Instance
resource "aws_instance" "web_server" {
ami = data.aws_ami.ubuntu.id
instance_type = "t2.micro"
key_name = "default-ec2" # <<-- Update this with your SSH key
iam_instance_profile = aws_iam_instance_profile.ec2_s3_profile.name
tags = {
Name = "testing-instance"
}
}
🔑 Notes:
Make sure you have an SSH key pair (
default-ec2
) in us-east-1. If not, create one in the AWS Console.Ensure your default security group in the default VPC allows inbound traffic on Port 22 (SSH).
Step 3: Configure Backend (backend.tf
)
terraform {
backend "s3" {
bucket = "my-pravesh-terraform-state-bucket-2025"
key = "terraform/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
This stores the state file remotely in S3 with encryption + locking enabled.
Step 4: Create the S3 Bucket
aws s3 mb s3://my-pravesh-terraform-state-bucket-2025
Step 5: Initialise and Deploy
terraform init --upgrade
terraform plan
terraform apply --auto-approve
💡 Terraform should show 5 resources to add. Within 2–3 minutes, your infrastructure will be ready!
Step 6: Test PoLP in Action
Go to your AWS Console → EC2 Dashboard → Select
testing-instance
→ Click Connect.SSH into the instance.
Install AWS CLI:
sudo apt update
sudo apt install awscli -y
- Try listing objects in the S3 bucket (works ✅):
aws s3 ls s3://my-pravesh-terraform-state-bucket-2025/terraform
- Try deleting the state file (blocked ❌):
aws s3 rm s3://my-pravesh-terraform-state-bucket-2025/terraform/terraform.tfstate
You’ll get AccessDenied, proving our PoLP IAM policy works! 🔐
Step 7: Scan with Terrascan
Install Terrascan from GitHub.
Then run it against your Terraform code:
terrascan scan -f main.tf
Terrascan will flag any misconfigurations or potential security risks before deployment.
Step 8: Safe State Management
Now let’s rename our EC2 resource without downtime.
Instead of changing web_server
→ app_server
in code and letting Terraform destroy/recreate the resource, use:
terraform state mv aws_instance.web_server aws_instance.app_server
You can verify the update by downloading the state file from the S3 console. No downtime, no drift—just clean state management. ✅
Step 9: Clean up
Once you are done with the project, make sure to use the following command to delete the resources to avoid incurring charges:
terraform destroy --auto-approve
aws s3 rm s3://my-pravesh-terraform-state-bucket-2025 --recursive
aws s3 rb s3://my-pravesh-terraform-state-bucket-2025
🎯 And that’s it!
🏁 Conclusion
In this project, we walked through five essential Terraform security best practices and then implemented them in a hands-on demonstration. From enabling state file encryption, configuring remote backends, enforcing version pinning, and using terrascan
for security scanning, to applying the Principle of Least Privilege (PLoP) with IAM roles and policies — we covered a complete workflow to secure Terraform in production-grade environments.
Security is never a one-time task; it’s an ongoing process. As your infrastructure grows, make sure you constantly revisit and update your security practices to minimize risks. With Terraform, it’s not just about provisioning resources — it’s about doing so securely, scalably, and responsibly.
If you found this guide useful, feel free to connect with me and check out more of my work 👇
🔗 Connect with me
🌐 Website/Blog: https://blog.praveshsudha.com
💼 LinkedIn: https://www.linkedin.com/in/pravesh-sudha/
🐦 Twitter/X: https://x.com/praveshstwt
🎥 YouTube: https://www.youtube.com/@pravesh-sudha
👋 Adios, see you in next one!
Top comments (0)