Building Predictable Infrastructure with Terraform Modules and State
A deep dive into how I use Terraform to manage cloud resources, focusing on the power of reusable modules and the importance of maintaining a healthy state file.
4 min read
When I first started building web applications in 2021, my approach to cloud infrastructure was mostly manual. I would log into the AWS Management Console, click through various screens, and hope I didn't forget a checkbox for a security group or a specific subnet configuration. While this works for small experiments, it quickly becomes a nightmare when you need to replicate environments or audit changes. This is why Terraform has become an essential part of my toolkit as a full stack developer. It allows me to define my infrastructure as code (IaC), using HashiCorp Configuration Language (HL) to describe exactly what I want my cloud environment to look like.
The Core Workflow: Init, Plan, and Apply
The standard Terraform workflow is deceptively simple, but it provides a rigorous framework for making changes. It starts with terraform init. This command initializes the working directory by downloading the provider plugins—such as the AWS or PostgreSQL providers—and setting up the backend where the state will be stored. I think of it as npm install for my infrastructure.
Next comes terraform plan, which is arguably the most important step in the process. When I run a plan, Terraform compares my local HCL code against the current state of the resources already deployed in the cloud. It generates an execution plan, showing me exactly what will be created, modified, or destroyed. This preview is my safety net. It allows me to catch errors, like an accidentally deleted database or a misconfigured VPC CIDR block, before they ever hit production. I make it a habit to always save my plan to a file using the -out flag to ensure that the exact changes I reviewed are the ones that get executed.
Finally, terraform apply carries out the actions proposed in the plan. Because Terraform is declarative, I don't tell it how to build a server; I tell it what the server should look like, and Terraform determines the correct order of operations to reach that desired state. This idempotency is key: running the same code multiple times results in the same infrastructure, regardless of the starting point.
Encapsulation with Modules
As my projects grow, I find that keeping all my resource definitions in a single file becomes unmanageable. This is where modules come in. A module is a container for multiple resources that are used together. Instead of defining an S3 bucket, its policy, and its lifecycle rules over and over again, I wrap them in a module. This allows me to encapsulate complexity and provide a clean interface for other parts of my configuration.
For instance, when setting up a PostgreSQL instance on AWS RDS, I don't just need the database itself. I need a subnet group, a security group with specific ingress rules, and an encrypted parameter group. By building a module, I can define these dependencies once and then instantiate the module with different variables for staging and production environments. Here is a simplified example of how I might call a custom module:
module "database" {
source = "./modules/rds"
instance_class = "db.t3.micro"
db_name = "app_db"
allocated_storage = 20
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnets
}This abstraction makes the top-level configuration much easier to read. Modules also encourage best practices. If I discover a better way to secure my S3 buckets, I can update the module in one place, and every project using that module will benefit from the improvement when I next run a plan and apply.
Managing State and Backends
The magic that allows Terraform to track what it has built is the state file. This file, usually named terraform.tfstate, acts as a source of truth that maps your configuration to the real-world resources. In the beginning, I kept this file on my local machine, but that is a dangerous practice. If the file is lost or corrupted, Terraform loses its memory of the infrastructure, and you are left with a mess of "orphaned" resources that you have to manually import or delete.
To solve this, I always use a remote backend. In my AWS projects, I configure Terraform to store the state file in an S3 bucket. To prevent two people (or two CI/CD jobs) from trying to modify the state at the same time, I also implement state locking using a DynamoDB table. This setup ensures that my infrastructure management is collaborative and safe.
Infrastructure as code is not just about automation; it is about building a searchable, version-controlled history of your entire system architecture.
When you use remote state, you can also take advantage of outputs. One project can export its VPC ID or database endpoint to the state file, and another project can read those values using a data source. This allows for a modular architecture where different parts of the stack—like networking, databases, and application services—can be managed in separate repositories while still staying connected.
Handling Infrastructure Drift
One of the challenges with any IaC tool is infrastructure drift. This happens when someone makes a manual change in the AWS console without updating the Terraform code. The next time I run terraform plan, Terraform will detect that the real-world resource no longer matches my code and will attempt to revert the manual change. While this can be frustrating if you were just "testing something quickly," it is actually a vital feature. It forces the team to keep the code as the ultimate authority, ensuring that our documentation (the code) always matches the reality of our production environment.
By sticking to a strict workflow of modules, remote state, and plan/apply cycles, I’ve found that I can manage complex cloud environments with the same confidence I have when writing application logic. It turns infrastructure from a source of anxiety into a predictable, repeatable part of the development lifecycle.
Have a project in mind?
I build and run web apps end to end.