Infrastructure as Code: Taking Control of Your Tech
As a full-stack developer working with TypeScript, Node.js, and MongoDB, you’re focused on building amazing applications. But what happens when the underlying infrastructure – the servers, databases, and networks – becomes a constant source of complexity and potential headaches? That’s where Infrastructure as Code (IaC) comes in.
Essentially, IaC is the practice of managing and provisioning infrastructure through code, rather than manual processes. Instead of logging into a server console to configure settings, you define your entire infrastructure – from server specifications to network configurations – using code files, typically in formats like YAML or JSON.
Think of it like this: you’d write code to create a virtual machine, install your Node.js runtime, configure your MongoDB database, and automate the entire deployment pipeline.
What’s the benefit for you?
IaC offers significant advantages. It automates deployment, dramatically reducing human error and accelerating your development cycles. You can version control your infrastructure, ensuring consistency across environments (development, staging, production). Testing becomes much simpler, and you gain a higher level of control and predictability over your entire application stack.
Tools like Terraform and Ansible are frequently used within the TypeScript/Node.js/MongoDB ecosystem, allowing you to seamlessly integrate IaC into your workflow. It’s about shifting from manual configuration to automated definition – empowering you to build and deploy applications with greater efficiency and reliability. This article focuses on Terraform
Terraform operates using a declarative approach to infrastructure management. Instead of telling it how to build something (imperative), you simply describe what you want – a specific server configuration, for example.
Terraform: Declare, Don’t Dictate
Terraform then figures out the optimal steps – provisioning, configuring, and updating – to achieve that desired state. It intelligently manages dependencies and ensures consistency across your infrastructure, automatically adapting to changes you define in your configuration files. Essentially, you declare your desired outcome, and Terraform handles the execution.
Core Components
1. The Terraform Binary:
- What it is: This is the core executable you use to interact with Terraform. It’s the tool that actually does the work – reading your configuration, executing the necessary commands, and managing your infrastructure.
- Think of it as: The engine of the Terraform system. You’ll run
terraformfrom your terminal to initiate changes.
2. Configuration Files (.tf files):
- What they are: These are text files written in the HashiCorp Configuration Language (HCL) that define your infrastructure. They contain the declarative descriptions of what you want – a server, a database, a network – rather than the imperative steps to create them.
- Think of it as: The blueprint for your infrastructure. You define what you need, and Terraform figures out how to achieve it.
3. Provider Plugins:
- What they are: These are plugins that connect Terraform to different cloud providers (AWS, Azure, Google Cloud) or other services (like Kubernetes). They provide the necessary logic to interact with those specific platforms.
- Think of it as: The translator. They allow Terraform to understand and interact with services like MongoDB, Kubernetes, or your chosen cloud provider.
4. State Data:
- What it is: Terraform keeps track of the current state of your infrastructure in a local state file (typically
terraform.tfstate). This file is critical because it’s how Terraform knows what’s been created, what’s still pending, and what needs to be updated. - Think of it as: Terraform’s memory. It’s essential for managing changes and ensuring that your infrastructure remains consistent. Never commit your state file to version control!
Terraform Workflow
1. Write (Configuration):
- You start by creating Terraform configuration files (typically
.tffiles) using HashiCorp’s Configuration Language (HCL). These files describe the desired state of your infrastructure – servers, networks, databases – focusing on what you want, not how to build it.
2. terraform init:
- Run
terraform init. This command initializes your Terraform working directory. It downloads required provider plugins, creates the backend (where Terraform stores state), and synthesizes the configuration for execution. Run this again when state backend, providers or modules changed.
3. Plan:
- Run
terraform plan. This command analyzes your configuration files and compares them to the current state (stored in theterraform.tfstatefile). - Terraform then generates an execution plan, outlining the exact changes it will make to reach your desired infrastructure state. You review this plan to ensure everything is correct.
4. Apply:
- Run
terraform apply. This command executes the plan, making the changes to your infrastructure through the configured Provider Plugins. - Terraform communicates with the cloud provider (e.g., AWS, Azure) or other services, provisioning and configuring everything according to your specifications.
There is also a command terraform destroy (also terraform apply -destroy) that will destroy all resources from state. Use with caution.
Throughout the process, Terraform maintains a persistent state, enabling you to manage changes efficiently and reliably.
The “Write” Phase: Building Your Infrastructure Blueprint with HCL
An overview
During the “Write” phase, you’re crafting your Terraform configuration using HashiCorp’s Configuration Language (HCL). Think of it as drafting the blueprint for your infrastructure – defining exactly what you want to create and manage. Here’s a breakdown of the key building blocks:
1. Resources: These are the fundamental components you define – servers, databases, networks, etc. Each resource represents an actual item in your infrastructure.
resource "aws_instance" "example" {
ami = "ami-0c55b98635c595189"
instance_type = "t2.micro"
}
2. Variables: These allow you to make your configuration reusable and adaptable. You define variables with a name, type, and default value.
variable "instance_type" {
type = string
default = "t2.micro"
description = "The EC2 instance type to launch"
}
3. Reference Values: References allow you to dynamically reference the state of existing resources, creating dependencies between them.
resource "aws_instance" "example" {
ami = "ami-0c55b98635c595189"
instance_type = var.instance_type
}
4. Modules: Reusable chunks of Terraform configuration. You can organize your code and reuse it across multiple projects.
5. Outputs: Used to export values generated by resources for use in other parts of your configuration or for external consumption. Output values are stored in state data. Output values can be passed from a child module to a parent module. For better organization you can specify outputs in a dedicated outputs.tf file.
output "instance_public_ip" {
value = aws_instance.example.public_ip
description = "The public IP address of the EC2 instance"
}
In essence, you’re using HCL to describe your desired state – what you want your infrastructure to look like. Terraform then figures out how to achieve that state, making the necessary changes to your cloud provider.
Harnessing Variables – Input & Output with Terraform
Terraform’s power lies in its ability to manage infrastructure dynamically. Variables are the key to achieving this – allowing you to tailor your configurations to specific environments and use cases. This chapter explores the concepts of input and output variables and how to effectively leverage them.
What are Terraform Variables?
Variables are placeholders in your Terraform configuration that hold values that can be changed without modifying the core code. They provide flexibility and reusability, reducing redundancy and making your infrastructure definitions more adaptable.
Input Variables – Gathering Information
Input variables are used to collect information before Terraform executes a terraform plan or terraform apply. They can be provided in several ways:
- Command-Line Arguments: You can pass values directly to the
terraform planorterraform applycommand using the-varflag:terraform apply -var="instance_type=t3.medium" terraform.tfvarsFile: This file (or a similarly named one, likevariables.tf) is the standard location for defining input variables. This is the most common approach. Terraform automatically loads files*.auto.tfvarsand*.auto.tfvars.json.- Environment Variables: You can set environment variables prefixed with
TF_VAR_. - Provider Block (Less Common): Some providers allow you to define input variables directly in the provider block.
Example variables.tf File:
variable "instance_type" {
type = string
default = "t2.micro" # Provide a default value
description = "The type of EC2 instance to create"
}
variable "region" {
type = string
default = "us-east-1"
description = "The AWS region to deploy to"
}
Output Variables – Sharing Information
Output variables allow you to retrieve information generated by your Terraform configuration after the terraform apply command has completed. This is incredibly useful for displaying details about the resources that have been created.
Example of using a list value:
variable "tags" {
type = list(string)
default = ["Name", "Environment"]
}
output "tags_list" {
value = var.tags
}
Data Types for Variables:
Terraform supports a wide range of data types for variables:
- String: Plain text (e.g., “hello”, “my_name”).
- Number: Integers or floating-point numbers (e.g., 10, 3.14).
- Boolean:
trueorfalse. - List: An ordered collection of values (e.g.,
["item1", "item2"]). - Map: A collection of key-value pairs (e.g.,
{"key1": "value1", "key2": "value2"}). - Complex List: List of complex objects (e.g. list of dictionaries).
- Object: A collection of key-value pairs (like a map).
Referencing List Values (Important!)
You can access individual elements within a list variable using array indices (starting from 0). The example above shows a simple reference.
Best Practices:
- Use Default Values: Always provide default values for variables, especially when they are optional.
- Descriptive Variable Names: Use clear and descriptive names for your variables to improve readability.
- Documentation: Include a
descriptionfor each variable to explain its purpose. - Organize Variables: Consistently use a
variables.tffile to store your variables – it promotes organization and maintainability.
Order of Evaluation:
TF_VAREnvironment Variablesterraform.tfvarsFile.auto.tfvarsFile-var-fileFlag-varFlag- Command Line Prompt
By mastering input and output variables, you’ll be well on your way to building truly dynamic and reusable Terraform configurations.
Polishing Your Code – Formatting and Validation with Terraform
Maintaining clean, readable Terraform code is paramount for effective collaboration and long-term maintainability. Terraform provides two key commands to achieve this: terraform fmt and terraform validate.
terraform fmt – Automatic Formatting
The terraform fmt command employs a configurable code formatter (typically gofmt) to automatically reformat your Terraform configuration files, enforcing a consistent style. It eliminates inconsistent indentation, spacing, and line breaks, significantly boosting readability. Simply run terraform fmt to apply the formatting – review the changes before staging and committing.
terraform validate – Ensuring Correct Syntax and Structure
Before applying any changes, it’s crucial to verify your configuration’s syntax and structure. terraform validate performs just that, checking your .tf files against Terraform’s syntax rules. It identifies and reports any errors or warnings without actually making any changes to your infrastructure. Run it with terraform validate to ensure your configuration is structurally sound.
The --check Flag – An Added Layer of Validation
Furthermore, the --check flag enhances terraform fmt. When used, it simply checks the format but does not correct it. Invalid formatting returns with an exit code 1. This can be useful to check in CI/CD.
In short, terraform fmt cleans up your code, while terraform validate ensures it’s correctly structured and ready for action. Using both together guarantees a solid foundation for your Terraform projects.
Initialization
Okay, let’s break down what happens after terraform init completes – and then tie it into the .terraform.lock.hcl file.
Post-terraform init:
After terraform init runs, Terraform effectively sets itself up for consistent and reproducible infrastructure changes. It’s not just downloading providers anymore. It’s solidifying its understanding of your environment. The .terraform file is created, storing critical version information about your configuration and providers. This ensures that everyone working on the project uses the exact same versions – preventing compatibility issues down the line.
.terraform.lock.hcl and NPM Lock Files:
The .terraform.lock.hcl file is Terraform’s equivalent of a lock file used by package managers like Yarn or pnpm. Just like Yarn locks down dependencies to ensure a consistent build environment, .terraform.lock.hcl forces Terraform to use specific versions of its providers.
Key difference: NPM lock files primarily manage JavaScript dependencies. The .terraform.lock.hcl file manages the infrastructure components (providers, modules) that Terraform uses, ensuring everyone is operating with the exact same “toolset” for building your infrastructure.
Essentially, both lock files are about creating predictable and reproducible environments – but they operate at different levels of abstraction.
Authentication
Before we continue with the planning phase, let’s explore the authentication methods Terraform uses with Google Cloud Platform (GCP). There are several ways to do this, each with its own advantages and drawbacks:
1. Service Account Key File:
- How it works: You download a JSON file containing credentials for a Google Cloud service account. You then pass this file to Terraform as an environment variable (
GOOGLE_APPLICATION_CREDENTIALS). - Pros: Simple, straightforward, widely used.
- Cons: Security risk – storing credentials directly in your code or environment. Less secure for automated deployments.
2. Google Cloud Credentials via Google Cloud SDK (gcloud):
- How it works: Terraform uses the credentials configured in your Google Cloud SDK (gcloud) installation. This is the most common method when running Terraform locally or in CI/CD pipelines where gcloud is already set up.
- Pros: Convenient, doesn’t require managing separate credentials files.
- Cons: Requires gcloud to be configured and authenticated, which can add complexity.
3. Google Container Registry (GCR) / Artifact Registry Authentication:
- How it Works: When deploying Terraform modules from GCR/Artifact Registry, Terraform automatically uses the service account associated with the registry.
- Pros: No explicit credential management needed for registry deployments.
- Cons: Only applicable when deploying modules from GCR/Artifact Registry.
4. Google Cloud Functions/Cloud Run Authentication (IAM Role Delegation):
- How it Works: You can configure a Google Cloud Function or Cloud Run service to assume a specific IAM role with the necessary permissions to access your GCP resources.
- Pros: Highly secure – avoids direct credential exposure.
- Cons: More complex to set up and manage compared to the simpler methods.
Summary Table:
| Method | Pros | Cons |
|---|---|---|
| Service Account Key File | Simple, Straightforward | Security Risk, Code Storage |
| gcloud Credentials | Convenient, No Credential Files | Requires gcloud Configuration |
| GCR/Artifact Registry | No Credential Management (Registry) | Limited to Registry Deployments |
| Cloud Functions/Run | Highly Secure | More Complex Setup & Management |
Recommendation: For most situations, using the Google Cloud SDK credentials is a good starting point. However, for production environments, leveraging IAM role delegation via Cloud Functions/Run offers the greatest security.
The Planning Phase – Shaping Your Infrastructure Changes
The “Plan” phase in Terraform is a crucial step – it’s where you validate your configuration and understand exactly what changes Terraform will make before actually executing them. This prevents costly surprises and ensures your infrastructure evolves predictably.
The Core Command: terraform plan
The terraform plan command is the heart of this phase. It analyzes your Terraform configuration files (typically .tf files) and compares them to the current state of your infrastructure in GCP (or your chosen cloud provider).
Here’s what happens:
- State File Analysis: Terraform consults its state file (usually named
terraform.tfstate) – which stores the current configuration of all your resources. - Configuration Comparison: It then compares your desired configuration with the existing state to identify the necessary changes.
- Change Preview: Finally, it generates an outline of the changes Terraform will make, including:
- Resources to create
- Resources to modify
- Resources to delete
Example Output:
The output of terraform plan will look something like this:
Terraform Graph Password:
Plan: aws_instance.example
Resource Actions:
- change: modify
resource: aws_instance.example
from:
ami: "ami-0c55b98635c595189"
instance_type: "t2.micro"
to:
ami: "ami-0c55b98635c595189"
instance_type: "t2.medium"
Your actions:
1) Apply configuration change
The -out Command Flag: Generating an Execution Plan
The -out command flag is a powerful tool that allows you to capture the output of terraform plan into a separate file, usually named terraform.plan.
- Purpose: This creates a human-readable plan file that you can review, share with others, or even use as input for automated deployments.
- Syntax:
terraform plan -out=terraform.plan - Benefits:
- Reviewing Changes: Easily examine the proposed changes before execution.
- Collaboration: Share the plan with stakeholders for approval.
- Automation: Integrate the plan into automated deployment pipelines.
The output file is in a special format. To read it use terraform show ./terraform.plan.
Workflow Summary
- Run
terraform planto generate the change outline. - Review the output (either on the console or in the
terraform.planfile). - If the changes look correct, proceed to the next phase:
terraform apply.
Important Note: The terraform.plan file is not an executable configuration. It’s simply a record of the planned changes.
Expressions and functions
Allows you to do things like
- Adding default tags and naming conventions
- Move startup script to a file
- Make public DNS a full URL
Local values
Local values and input variables in Terraform serve distinct roles. Local values are internal configuration pieces – calculations or reusable logic within a single file. They’re evaluated during the initial configuration phase and aren’t stored in the state. Conversely, input variables are external values, defined in separate files, that drive your infrastructure. They’re read and applied during the “apply” phase and become part of the Terraform state, allowing for dynamic control over your configuration.
Local values are internal temporary values. They replace repeated values and help transform data to make code more readable. It starts with a locals keyword. We can reference input variables with var.something and use interpolation like ${tag_prefix}.
variable "app_prefix" {
type = string
description = "The prefix for our application instance names."
default = "my-app-"
}
locals {
instance_prefix = var.app_prefix # Using the input variable
tag_prefix = "project-"
tag_environment = "dev"
tag_name_format = "${tag_prefix}${tag_environment}"
}
resource "aws_instance" "example" {
ami = "ami-0c55b2986f2d2196b"
instance_type = "t2.micro"
tags = {
Environment = local.tag_environment
Project = local.tag_prefix
Name = "example-instance"
}
}
Functions and expressions
We can use literal expressions, object or attribute references, arithmetic and logic operators, conditional expressions and for expressions.
# This example demonstrates various Terraform features:
# - Literal expressions
# - Object and Attribute References
# - Arithmetic and Logic Operators
# - Conditional Expressions
# - For Expressions
# Define a variable for the number of instances
variable "num_instances" {
type = number
default = 3
}
# Create a local variable for a discount percentage
local "discount_rate" {
type = number
value = 0.10 # 10% discount
}
# Define a resource block for an EC2 instance
resource "aws_instance" "example" {
ami = "ami-0c55b851900962e53" # Replace with a valid AMI ID
instance_type = "t2.micro"
availability_zone = "us-east-1a"
# Literal expression - Concatenating strings
instance_name = "instance-${var.num_instances}"
# Object and Attribute Reference - Accessing the instance type
tags = {
Name = "Example Instance ${var.num_instances}"
}
# Arithmetic Operator - Using 'for_each' to create multiple instances
# This will create 3 instances with varying sizes.
for_each = toset(["t2.micro", "t2.small", "t2.medium"])
# Logic Operator - Ensuring at least one instance is created
count = var.num_instances > 0 ? 1 : 0
# Conditional Expression - Enabling HTTP access only if num_instances is greater than 1
# Note: this is a placeholder and doesn't actually configure HTTP access.
# It's just demonstrating the conditional expression syntax.
lifecycle {
provisioning {
ignore_changes = [
# We could list attributes here to ignore changes
]
}
}
}
# For Expression - Creating a list of instance names based on the number of instances
locals {
instance_names = ["instance-1", "instance-2", "instance-3"]
}
# Output the instance IDs
output "instance_ids" {
value = [for id in aws_instance.example : id.id]
}
# Output the instance names
output "instance_names" {
value = [for name in aws_instance.example : name.instance_name]
}
Template files
The following example demonstrates how to use Terraform’s templatefile() function.
Example Terraform File (main.tf)
# main.tf
# Variables that we want to pass into the template
variable "app_name" {
type = string
description = "Name of the application"
}
variable "environment" {
type = string
description = "Deployment environment (e.g. dev, staging, prod)"
}
variable "replica_count" {
type = number
description = "Number of replicas to deploy"
default = 2
}
# Local value that renders the template file
locals {
rendered_config = templatefile(
"${path.module}/app-config.tftpl", # Path to template file
# Map of variables passed into the template
{
app_name = var.app_name
environment = var.environment
replica_count = var.replica_count
}
)
}
# Output just for demonstration purposes
output "rendered_config" {
value = local.rendered_config
}
Example Template File (app-config.tftpl)
# app-config.tftpl
# This is a plain text file.
# Terraform interpolation syntax works inside it.
application:
name: ${app_name}
environment: ${environment}
deployment:
replicas: ${replica_count}
# Conditional logic is supported
high_availability: ${replica_count > 1 ? "enabled" : "disabled"}
# You can also iterate over collections if passed in.
If Terraform is run with:
terraform apply \ -var="app_name=my-service" \ -var="environment=production"
The rendered output would be:
application: name: my-service environment: production deployment: replicas: 2 high_availability: enabled
Why templatefile() Is Useful
In my opinion, templatefile() is particularly valuable when:
- Generating configuration files (YAML, JSON, shell scripts)
- Rendering Kubernetes manifests
- Producing user-data scripts for cloud instances
- Avoiding large inline heredocs in Terraform files
It keeps Terraform code clean while still allowing dynamic content generation.