Tutorial 25: Configuration Validation and Best Practices
Learning Objectives
By the end of this tutorial, you will be able to:
- Implement comprehensive configuration validation strategies
- Apply Terraform best practices for maintainable infrastructure code
- Use automated testing and validation tools
- Design secure and scalable Terraform configurations
- Establish effective code organization and documentation patterns
Prerequisites
- Completed all previous Configuration module tutorials (16-24)
- Understanding of Terraform lifecycle and state management
- Knowledge of infrastructure security principles
Introduction
This tutorial consolidates best practices for Terraform configuration management, covering validation strategies, code organization, security practices, and testing methodologies. These practices ensure your infrastructure code is maintainable, secure, and reliable at scale.
Configuration Validation
Input Validation
# variables.tf
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
validation {
condition = can(regex("^[tm][0-9]+[a-z]*\\.(nano|micro|small|medium|large|xlarge|[0-9]+xlarge)$", var.instance_type))
error_message = "Instance type must be a valid EC2 instance type (e.g., t3.micro, m5.large)."
}
}
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "VPC CIDR must be a valid IPv4 CIDR block."
}
validation {
condition = tonumber(split("/", var.vpc_cidr)[1]) <= 24
error_message = "VPC CIDR block must be /24 or larger (smaller prefix)."
}
}
variable "tags" {
description = "Resource tags"
type = map(string)
default = {}
validation {
condition = alltrue([
for k, v in var.tags : can(regex("^[a-zA-Z][a-zA-Z0-9\\-_]*$", k))
])
error_message = "Tag keys must start with a letter and contain only alphanumeric characters, hyphens, and underscores."
}
validation {
condition = alltrue([
for k, v in var.tags : length(v) > 0 && length(v) <= 256
])
error_message = "Tag values must be between 1 and 256 characters."
}
}
variable "backup_schedule" {
description = "Backup schedule configuration"
type = object({
enabled = bool
frequency = string
retention = number
time = string
})
default = {
enabled = false
frequency = "daily"
retention = 7
time = "03:00"
}
validation {
condition = contains(["daily", "weekly", "monthly"], var.backup_schedule.frequency)
error_message = "Backup frequency must be daily, weekly, or monthly."
}
validation {
condition = var.backup_schedule.retention >= 1 && var.backup_schedule.retention <= 365
error_message = "Backup retention must be between 1 and 365 days."
}
validation {
condition = can(regex("^([01]?[0-9]|2[0-3]):[0-5][0-9]$", var.backup_schedule.time))
error_message = "Backup time must be in HH:MM format (24-hour)."
}
}
variable "allowed_ports" {
description = "List of allowed ports"
type = list(number)
default = [80, 443]
validation {
condition = alltrue([
for port in var.allowed_ports : port >= 1 && port <= 65535
])
error_message = "All ports must be between 1 and 65535."
}
validation {
condition = length(distinct(var.allowed_ports)) == length(var.allowed_ports)
error_message = "Port list must not contain duplicates."
}
}
variable "database_config" {
description = "Database configuration"
type = object({
engine = string
version = string
instance_class = string
storage_gb = number
multi_az = bool
encrypted = bool
})
validation {
condition = contains(["mysql", "postgres", "mariadb"], var.database_config.engine)
error_message = "Database engine must be mysql, postgres, or mariadb."
}
validation {
condition = var.database_config.storage_gb >= 20 && var.database_config.storage_gb <= 65536
error_message = "Database storage must be between 20 and 65536 GB."
}
validation {
condition = can(regex("^db\\.[tm][0-9]+\\.(nano|micro|small|medium|large|xlarge|[0-9]+xlarge)$", var.database_config.instance_class))
error_message = "Database instance class must be a valid RDS instance type."
}
}
Advanced Validation Patterns
# validation.tf
locals {
# Complex validation logic
validation_results = {
# Check environment consistency
environment_valid = (
var.environment == "prod" ?
var.database_config.multi_az && var.database_config.encrypted :
true
)
# Validate network configuration
network_valid = (
length(var.subnet_cidrs) <= pow(2, (24 - tonumber(split("/", var.vpc_cidr)[1]))) - 2
)
# Check security requirements
security_valid = (
var.environment == "prod" ?
contains(var.allowed_ports, 443) && !contains(var.allowed_ports, 22) :
true
)
# Validate resource naming
naming_valid = (
can(regex("^[a-z][a-z0-9-]*[a-z0-9]$", var.project_name)) &&
length(var.project_name) >= 3 &&
length(var.project_name) <= 63
)
}
# Aggregate validation
all_validations_passed = alltrue(values(local.validation_results))
}
# Custom validation checks
resource "null_resource" "validation_check" {
count = local.all_validations_passed ? 0 : 1
triggers = {
validation_error = "Configuration validation failed: ${jsonencode(local.validation_results)}"
}
lifecycle {
precondition {
condition = local.validation_results.environment_valid
error_message = "Production environments must have multi-AZ and encryption enabled."
}
precondition {
condition = local.validation_results.network_valid
error_message = "Too many subnets for the specified VPC CIDR block."
}
precondition {
condition = local.validation_results.security_valid
error_message = "Production environments must use HTTPS and not expose SSH."
}
precondition {
condition = local.validation_results.naming_valid
error_message = "Project name must be 3-63 characters, start with letter, end with letter/number."
}
}
}
# Output validation results for debugging
output "validation_results" {
description = "Configuration validation results"
value = local.validation_results
}
Code Organization Best Practices
File Structure Standards
terraform-project/
āāā README.md
āāā .gitignore
āāā .terraform-version
āāā .tflint.hcl
āāā environments/
ā āāā dev/
ā ā āāā main.tf
ā ā āāā variables.tf
ā ā āāā outputs.tf
ā ā āāā terraform.tfvars
ā ā āāā backend.tf
ā āāā staging/
ā ā āāā ...
ā āāā prod/
ā āāā ...
āāā modules/
ā āāā vpc/
ā ā āāā main.tf
ā ā āāā variables.tf
ā ā āāā outputs.tf
ā ā āāā versions.tf
ā ā āāā README.md
ā āāā compute/
ā ā āāā ...
ā āāā database/
ā āāā ...
āāā shared/
ā āāā data.tf
ā āāā locals.tf
ā āāā variables.tf
āāā scripts/
ā āāā deploy.sh
ā āāā validate.sh
ā āāā test.sh
āāā tests/
āāā unit/
āāā integration/
āāā end-to-end/
Module Structure Template
# modules/example/versions.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
# modules/example/variables.tf
variable "project_name" {
description = "Name of the project"
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]*[a-z0-9]$", var.project_name))
error_message = "Project name must start with a letter, contain only lowercase letters, numbers, and hyphens, and end with a letter or number."
}
}
variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "tags" {
description = "Common tags to apply to all resources"
type = map(string)
default = {}
}
# modules/example/locals.tf
locals {
# Common naming convention
name_prefix = "${var.project_name}-${var.environment}"
# Standard tags applied to all resources
common_tags = merge(var.tags, {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
Module = basename(abspath(path.module))
CreatedAt = formatdate("YYYY-MM-DD", timestamp())
})
# Environment-specific configurations
environment_config = {
dev = {
instance_type = "t3.micro"
min_instances = 1
max_instances = 2
}
staging = {
instance_type = "t3.small"
min_instances = 1
max_instances = 3
}
prod = {
instance_type = "t3.medium"
min_instances = 2
max_instances = 10
}
}
config = local.environment_config[var.environment]
}
# modules/example/main.tf
# Resource implementation with consistent patterns
# modules/example/outputs.tf
output "example_id" {
description = "ID of the example resource"
value = aws_example.main.id
}
output "example_arn" {
description = "ARN of the example resource"
value = aws_example.main.arn
}
output "example_endpoint" {
description = "Endpoint of the example resource"
value = aws_example.main.endpoint
sensitive = contains(["staging", "prod"], var.environment)
}
# modules/example/README.md
```markdown
# Example Module
This module creates and manages example resources.
## Usage
```hcl
module "example" {
source = "./modules/example"
project_name = "my-project"
environment = "dev"
tags = {
Owner = "[email protected]"
}
}
Requirements
Name | Version |
---|
terraform | >= 1.0 |
aws | ~> 5.0 |
Providers
Inputs
Name | Description | Type | Default | Required |
---|
project_name | Name of the project | string | n/a | yes |
environment | Environment name | string | n/a | yes |
tags | Common tags | map(string) | {} | no |
Outputs
Name | Description |
---|
example_id | ID of the example resource |
example_arn | ARN of the example resource |
Environment Configuration Pattern
# environments/base/variables.tf
variable "project_name" {
description = "Name of the project"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "aws_region" {
description = "AWS region"
type = string
default = "us-west-2"
}
variable "common_tags" {
description = "Common tags for all resources"
type = map(string)
default = {}
}
# environments/base/main.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = merge(var.common_tags, {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
})
}
}
# Data sources
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# Local values
locals {
# Consistent naming
name_prefix = "${var.project_name}-${var.environment}"
# Network configuration
vpc_cidr = {
dev = "10.0.0.0/16"
staging = "10.1.0.0/16"
prod = "10.2.0.0/16"
}[var.environment]
# Availability zones (limit to 3 for consistency)
availability_zones = slice(data.aws_availability_zones.available.names, 0, 3)
# Common tags
common_tags = merge(var.common_tags, {
Project = var.project_name
Environment = var.environment
Region = data.aws_region.current.name
AccountId = data.aws_caller_identity.current.account_id
})
}
# Module instantiation
module "vpc" {
source = "../../modules/vpc"
project_name = var.project_name
environment = var.environment
vpc_cidr = local.vpc_cidr
availability_zones = local.availability_zones
tags = local.common_tags
}
module "security" {
source = "../../modules/security"
project_name = var.project_name
environment = var.environment
vpc_id = module.vpc.vpc_id
vpc_cidr = module.vpc.vpc_cidr
tags = local.common_tags
}
# environments/dev/main.tf
module "infrastructure" {
source = "../base"
project_name = "myapp"
environment = "dev"
aws_region = "us-west-2"
common_tags = {
Owner = "development-team"
CostCenter = "engineering"
Application = "myapp"
}
}
# environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "myapp/dev/terraform.tfstate"
region = "us-west-2"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
# environments/dev/terraform.tfvars
# Development-specific overrides
Security Best Practices
Secrets Management
# security/secrets.tf
# DO NOT store secrets in plain text
# Use AWS Secrets Manager
resource "aws_secretsmanager_secret" "db_password" {
name = "${local.name_prefix}-db-password"
description = "Database password for ${local.name_prefix}"
recovery_window_in_days = var.environment == "prod" ? 30 : 0
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = jsonencode({
username = var.db_username
password = random_password.db_password.result
})
}
resource "random_password" "db_password" {
length = 32
special = true
keepers = {
# Change password when these values change
username = var.db_username
version = "v1"
}
}
# Reference secrets in resources
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
}
locals {
db_credentials = jsondecode(data.aws_secretsmanager_secret_version.db_password.secret_string)
}
resource "aws_db_instance" "main" {
identifier = "${local.name_prefix}-database"
engine = "mysql"
engine_version = "8.0"
instance_class = var.db_instance_class
allocated_storage = 20
storage_encrypted = true
kms_key_id = aws_kms_key.database.arn
db_name = var.db_name
username = local.db_credentials.username
password = local.db_credentials.password
# Security group allows only application access
vpc_security_group_ids = [aws_security_group.database.id]
db_subnet_group_name = aws_db_subnet_group.main.name
backup_retention_period = var.environment == "prod" ? 30 : 7
backup_window = "03:00-04:00"
maintenance_window = "sun:04:00-sun:05:00"
deletion_protection = var.environment == "prod"
skip_final_snapshot = var.environment != "prod"
tags = local.common_tags
}
# KMS key for encryption
resource "aws_kms_key" "database" {
description = "KMS key for ${local.name_prefix} database encryption"
deletion_window_in_days = var.environment == "prod" ? 30 : 7
tags = local.common_tags
}
resource "aws_kms_alias" "database" {
name = "alias/${local.name_prefix}-database"
target_key_id = aws_kms_key.database.key_id
}
IAM Best Practices
# security/iam.tf
# Principle of least privilege
# Application role with minimal permissions
resource "aws_iam_role" "app" {
name = "${local.name_prefix}-app-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
Condition = {
StringEquals = {
"aws:RequestedRegion" = data.aws_region.current.name
}
}
}
]
})
tags = local.common_tags
}
# Specific policy for application needs
resource "aws_iam_role_policy" "app" {
name = "${local.name_prefix}-app-policy"
role = aws_iam_role.app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue"
]
Resource = [
aws_secretsmanager_secret.db_password.arn
]
Condition = {
StringEquals = {
"secretsmanager:ResourceTag/Project" = var.project_name
}
}
},
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject"
]
Resource = [
"${aws_s3_bucket.app.arn}/*"
]
},
{
Effect = "Allow"
Action = [
"kms:Decrypt",
"kms:GenerateDataKey"
]
Resource = [
aws_kms_key.database.arn
]
Condition = {
StringEquals = {
"kms:ViaService" = "secretsmanager.${data.aws_region.current.name}.amazonaws.com"
}
}
}
]
})
}
# Instance profile
resource "aws_iam_instance_profile" "app" {
name = "${local.name_prefix}-app-profile"
role = aws_iam_role.app.name
tags = local.common_tags
}
# Security group with restrictive rules
resource "aws_security_group" "app" {
name_prefix = "${local.name_prefix}-app-"
vpc_id = var.vpc_id
description = "Security group for ${local.name_prefix} application"
# Only allow HTTPS inbound
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Restrictive outbound rules
egress {
description = "HTTPS to internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Database access"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.database.id]
}
lifecycle {
create_before_destroy = true
}
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-app-sg"
})
}
# Database security group - only app access
resource "aws_security_group" "database" {
name_prefix = "${local.name_prefix}-db-"
vpc_id = var.vpc_id
description = "Security group for ${local.name_prefix} database"
ingress {
description = "MySQL from app"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
# No outbound rules needed for RDS
lifecycle {
create_before_destroy = true
}
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-db-sg"
})
}
Testing and Validation
Automated Testing
# tests/terratest/example_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestTerraformExample(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../environments/dev",
Vars: map[string]interface{}{
"project_name": "test-project",
"environment": "dev",
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": "us-west-2",
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Test outputs
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
// Test resource properties
subnetIds := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
assert.Equal(t, 3, len(subnetIds))
}
Validation Scripts
#!/bin/bash
# scripts/validate.sh
set -e
echo "š Running Terraform validation..."
# Check format
echo "Checking Terraform format..."
terraform fmt -check -recursive .
# Validate syntax
echo "Validating Terraform syntax..."
find . -name "*.tf" -exec dirname {} \; | sort -u | while read dir; do
echo "Validating $dir..."
(cd "$dir" && terraform init -backend=false && terraform validate)
done
# Run security scanning
echo "Running security scan..."
if command -v tfsec &> /dev/null; then
tfsec .
else
echo "ā ļø tfsec not installed. Install with: brew install tfsec"
fi
# Run linting
echo "Running Terraform lint..."
if command -v tflint &> /dev/null; then
tflint --recursive
else
echo "ā ļø tflint not installed. Install with: brew install tflint"
fi
# Check for common issues
echo "Checking for common issues..."
# Check for hardcoded secrets
if grep -r "password.*=" --include="*.tf" --include="*.tfvars" .; then
echo "ā Found potential hardcoded passwords"
exit 1
fi
# Check for hardcoded IPs
if grep -rE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" --include="*.tf" . | grep -v "0.0.0.0/0" | grep -v "127.0.0.1"; then
echo "ā ļø Found hardcoded IP addresses"
fi
echo "ā
Validation completed successfully!"
Pre-commit Hooks
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.81.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_docs
args:
- --hook-config=--path-to-file=README.md
- --hook-config=--add-to-existing-file=true
- --hook-config=--create-file-if-not-exist=true
- id: terraform_tflint
args:
- --args=--only=terraform_deprecated_interpolation
- --args=--only=terraform_deprecated_index
- --args=--only=terraform_unused_declarations
- --args=--only=terraform_comment_syntax
- --args=--only=terraform_documented_outputs
- --args=--only=terraform_documented_variables
- --args=--only=terraform_typed_variables
- --args=--only=terraform_module_pinned_source
- --args=--only=terraform_naming_convention
- --args=--only=terraform_required_version
- --args=--only=terraform_required_providers
- --args=--only=terraform_standard_module_structure
- id: terraform_tfsec
args:
- --args=--minimum-severity=MEDIUM
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
Documentation Standards
Module Documentation Template
# Module Name
Brief description of what this module does and why you would use it.
## Usage
```hcl
module "example" {
source = "path/to/module"
# Required variables
project_name = "my-project"
environment = "dev"
# Optional variables
instance_type = "t3.medium"
tags = {
Owner = "[email protected]"
}
}
Architecture
Brief explanation of the architecture and components.
Security Considerations
- List security features and considerations
- Mention encryption, access controls, etc.
- Note any security requirements or assumptions
Requirements
Name | Version |
---|
terraform | >= 1.0 |
aws | ~> 5.0 |
Providers
Name | Version |
---|
aws | ~> 5.0 |
random | ~> 3.0 |
Modules
Name | Source | Version |
---|
example | ./modules/example | n/a |
Resources
Name | Type |
---|
aws_instance.example | resource |
aws_security_group.example | resource |
Inputs
Name | Description | Type | Default | Required |
---|
project_name | Name of the project | string | n/a | yes |
environment | Environment name | string | n/a | yes |
instance_type | EC2 instance type | string | "t3.micro" | no |
Outputs
Name | Description |
---|
instance_id | ID of the EC2 instance |
security_group_id | ID of the security group |
Examples
Basic Example
module "basic_example" {
source = "./modules/example"
project_name = "my-project"
environment = "dev"
}
Advanced Example
module "advanced_example" {
source = "./modules/example"
project_name = "my-project"
environment = "prod"
instance_type = "t3.large"
tags = {
Owner = "platform-team"
CostCenter = "engineering"
Environment = "production"
}
}
Contributing
Guidelines for contributing to this module.
License
License information.
### Repository Documentation
```markdown
# Project Infrastructure
This repository contains Terraform configurations for managing our infrastructure.
## Structure
āāā environments/ # Environment-specific configurations
ā āāā dev/ # Development environment
ā āāā staging/ # Staging environment
ā āāā prod/ # Production environment
āāā modules/ # Reusable Terraform modules
āāā shared/ # Shared configurations and data
āāā scripts/ # Automation scripts
āāā tests/ # Infrastructure tests
## Getting Started
### Prerequisites
- Terraform >= 1.0
- AWS CLI configured
- Pre-commit hooks (optional but recommended)
### Initial Setup
1. Clone the repository
2. Install dependencies: `./scripts/setup.sh`
3. Configure AWS credentials
4. Initialize Terraform: `terraform init`
### Deployment
```bash
# Deploy to development
cd environments/dev
terraform plan
terraform apply
# Deploy to production
cd environments/prod
terraform plan
terraform apply
Best Practices
- Always run
terraform plan
before apply
- Use feature branches for changes
- Run validation:
./scripts/validate.sh
- Keep modules focused and reusable
- Document all variables and outputs
- Use semantic versioning for modules
Security
- Never commit secrets to version control
- Use AWS Secrets Manager for sensitive data
- Apply principle of least privilege for IAM
- Enable encryption at rest and in transit
- Regular security scanning with tfsec
Support
For questions or issues, contact the platform team.
## Key Takeaways
1. **Validation**: Implement comprehensive input validation with clear error messages
2. **Organization**: Follow consistent file and module organization patterns
3. **Security**: Apply security best practices including secrets management and IAM
4. **Testing**: Use automated testing and validation tools
5. **Documentation**: Maintain clear, comprehensive documentation
6. **Standards**: Establish and enforce coding standards and conventions
## Next Steps
You've now completed the Configuration Management module! You should:
1. Practice implementing these patterns in your own projects
2. Set up automated validation pipelines
3. Establish team coding standards
4. Continue with the State Management module (Tutorials 26-30)
5. Review the [Terraform Best Practices Guide](https://terraform.io/docs/cloud/guides/recommended-practices/index.html)
## Additional Resources
- [Terraform Style Guide](https://www.terraform.io/docs/language/syntax/style.html)
- [TFSec Security Scanner](https://aquasecurity.github.io/tfsec/)
- [TFLint Linter](https://github.com/terraform-linters/tflint)
- [Terratest Testing Framework](https://terratest.gruntwork.io/)
- [Pre-commit Terraform Hooks](https://github.com/antonbabenko/pre-commit-terraform)