Terraform Best Practices: "Hello, World" Example

Terraform Best Practices: "Hello, World" Example
Hello world lambda

Introduction

In this post, I’ll walk through best practices for writing production-grade Terraform code—the kind that scales with your team and your infrastructure. We’ll cover principles like modularity, naming conventions and environment isolation

Finally, I’ll show a minimal but realistic “Hello, World” example: deploying an AWS Lambda function from a container image stored in ECR. It’s small but touches on the fundamentals: modular structure, IAM permissions, environment separation, and clean inputs/outputs.


1. Modularity and Reusability

The foundation of good Terraform is modules. A module is just a folder containing Terraform code that encapsulates a logical unit of infrastructure. Instead of having one giant file with hundreds of resources, break things down into small, single-purpose modules.

For example:

  • modules/ecr-repository/ → defines one ECR repo
  • modules/lambda-container-function/ → defines one Lambda function and its IAM role

This structure makes your code reusable, testable, and easier to review.

Each module should follow a consistent internal structure:

  • main.tf → the actual resources
  • variables.tf → input variables
  • outputs.tf → outputs
  • versions.tf → provider and Terraform version constraints

This is the structure advocated by Anton Babenko and many in the Terraform community, and it keeps things predictable.


2. Naming Conventions

Names matter. Consistent naming prevents confusion and errors. Follow these rules:

  • Use underscores (_), not hyphens (-).
  • Stick to lowercase letters and numbers.
  • Don’t repeat resource types in names. For example:
# Good
resource "aws_route_table" "public" {}

# Bad
resource "aws_route_table" "public_route_table" {}

The first is concise, readable, and avoids redundancy.


3. Output Variables

Outputs are the contract between modules. To keep them clean:

  • Always add a description.
  • Use plural names for lists (e.g., subnet_ids).
  • Avoid marking outputs sensitive = true unless absolutely required, because it makes debugging harder.

4. Environment Isolation

Never deploy dev and prod from the same configuration. That’s a recipe for mistakes. Instead, use a directory structure like this:

live/
  dev/
    main.tf
  prod/
    main.tf

Each environment has its own state file, variables, and configuration. This gives true isolation and prevents a typo in dev from wiping prod. Workspaces are not a substitute for real environment separation.


5. Secrets Management

Hard-coding secrets in Terraform is one of the fastest ways to end up in an incident report. Don’t do it.

  • For local runs: use environment variables with the TF_VAR_ prefix.
  • For CI/CD: use IAM Roles with OIDC or secrets injected securely by the pipeline.
  • Consider tools like AWS Secrets Manager or HashiCorp Vault for production secrets.

Always mark sensitive inputs with:

variable "db_password" {
  type      = string
  sensitive = true
}

This ensures they won’t leak into logs or plans.


6. Code Quality and Automation

Terraform has built-in and external tools to help you keep quality high:

  • terraform fmt → enforce style. Run it automatically in CI.
  • terraform validate → catch syntax errors.
  • terraform plan → always review plans before applying. Integrate this into pull requests (e.g., Atlantis, Spacelift, GitHub Actions).
  • terraform-docs → auto-generate README docs for modules.

Use comments sparingly, and only for why, not what. The code already shows what.


7. Testing Infrastructure

Infrastructure code deserves tests just like application code. At a minimum:

  • Use sandbox accounts for manual testing.
  • Add static analysis with tflint and tfsec.

A "Hello, World" Example: Lambda with Container Image

Let’s apply these best practices with a simple example: an AWS Lambda function that runs from a Docker image stored in ECR. This setup has three moving parts:

  1. ECR Repository → stores the container image.
  2. Lambda Function → runs from that image.
  3. IAM Role → allows Lambda to write logs to CloudWatch.

This is minimal, but it demonstrates modularity, IAM least privilege, and environment separation.


Module: ecr-repository

main.tf

resource "aws_ecr_repository" "main" {
  name = var.name
}

output "repository_url" {
  description = "URL of the ECR repository"
  value       = aws_ecr_repository.main.repository_url
}

variables.tf

variable "name" {
  description = "ECR repository name"
  type        = string
}

Module: lambda-container-function

main.tf

data "aws_iam_policy_document" "lambda_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "lambda_exec" {
  name               = "${var.name}_exec"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

resource "aws_iam_role_policy_attachment" "lambda_logging" {
  role       = aws_iam_role.lambda_exec.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_lambda_function" "main" {
  function_name = var.name
  role          = aws_iam_role.lambda_exec.arn
  package_type  = "Image"
  image_uri     = var.image_uri
  timeout       = 10
}

output "lambda_arn" {
  description = "ARN of the Lambda function"
  value       = aws_lambda_function.main.arn
}

variables.tf

variable "name" {
  description = "Name of the Lambda function"
  type        = string
}

variable "image_uri" {
  description = "ECR image URI for the Lambda"
  type        = string
}

Environment: live/dev/main.tf

module "ecr" {
  source = "../../modules/ecr-repository"
  name   = "hello_dev_repo"
}

module "lambda" {
  source    = "../../modules/lambda-container-function"
  name      = "hello_dev_lambda"
  image_uri = "${module.ecr.repository_url}:latest"
}

Why this matters

This example might look simple, but it demonstrates several best practices:

  • Modularity → each resource type is isolated into its own module.
  • IAM least privilege → Lambda gets only what it needs (CloudWatch logging).
  • Outputs → modules communicate cleanly.
  • Environment isolationlive/dev contains its own config, separate from prod.

From here, you can expand with VPCs, databases, or API Gateways, while keeping the same principles.


Conclusion

Terraform gives us the power to define infrastructure as code. By sticking to best practices—modularity, naming conventions, environment isolation and secrets management —you create IaC that scales with your team and your workloads.

The Lambda + ECR “Hello, World” may be simple, but it captures the essence of production-grade Terraform: clean modules, minimal IAM, clear inputs/outputs, and isolated environments. Build on these foundations, and your Terraform codebase will remain reliable as your infrastructure grows.

Read more