LocalStack and Terragrunt
Streamlining Cloud Development with LocalStack and Terragrunt
The landscape of cloud development is constantly evolving. With a growing emphasis on efficiency, consistency, and cost management we need a way to build locally to minimize cloud costs. My new scaffold project addresses these challenges head-on by providing a robust framework for managing AWS infrastructure locally fusing together LocalStack and Terraform through Terragrunt. It is built to allow for rapid development and is designed to be flexible and extensible.
The Big Problem: Cloud Development Challenges
Developing and testing infrastructure code directly on a cloud provider like AWS can be slow and expensive. Each change requires a deployment cycle, leading to long feedback loops and potentially high costs. This issue is a plague for us currently, as we have to wait for terragrunt apply
’s to finish in Github Actions, and the process if extremely time consuming for our software engineers. Our apply infrastructure is also problematic, as we can’t currently apply from local machines without destroying state remotely. Furthermore, maintaining consistent environments across different stages, from an engineer’s machine to the production environment, can be a significant challenge.
The Solution: A Hybrid Approach
This project offers a solution by combining the power of two key technologies:
LocalStack: A local cloud service emulator that provides an API for common AWS services. This allows software engineers to build and test their applications and infrastructure locally, without an internet connection and without incurring any AWS charges. It is entirely hosted locally in Docker containers.
Terragrunt: A thin wrapper for Terraform that keeps your configurations DRY (Don’t Repeat Yourself). Terragrunt helps manage multiple environments by allowing you to define your infrastructure once and reuse it across different stages, such as dev, staging, and prod.
Key Features of the Scaffold
My project is designed with best practices in mind, offering several key features that benefit engineering teams:
Cost-Effective Local Development: By using LocalStack, software engineers can iterate on infrastructure changes without spending a dime on AWS services. This is especially useful for early-stage development, new feature development, and prototyping/testing.
Consistent Multi-Environment Deployment: The Terragrunt setup ensures that the same Terraform modules and configurations are used for both the local and production environments. This dramatically reduces the risk of environment-specific bugs and drift.
Modular and Reusable Code: The scaffold is built on reusable Terraform modules. This approach promotes maintainability and allows teams to build new infrastructure quickly by assembling pre-tested components.
Automated Testing: The project includes a framework for automated tests to validate that the infrastructure behaves as expected, adding a layer of reliability to the deployment process.
Production Safeguards: Terragrunt’s workflow, which includes plan and apply stages, provides a clear and controlled way to manage changes in production. This allows for peer review and confirmation before any resources are modified.
How It Works
The core of the scaffold lies in its directory structure. It separates reusable Terraform modules from the environment-specific Terragrunt configuration files. This separation allows you to define a resource (e.g., an S3 bucket) once as a Terraform module and then instantiate it multiple times using Terragrunt for different environments (e.g., a local bucket and a production bucket). The LocalStack setup is integrated to automatically use the local endpoint when a specific terragrunt.hcl file is executed in the local environment.
AWS Example Structure
graph TD
%% Style definitions
classDef s3 fill:#FF9900,stroke:#FF9900,color:white,stroke-width:2px
classDef sqs fill:#6EBF49,stroke:#6EBF49,color:white,stroke-width:2px
classDef lambda fill:#FF5252,stroke:#FF5252,color:white,stroke-width:2px
classDef iam fill:#5F83BE,stroke:#5F83BE,color:white,stroke-width:2px
%% Nodes
Bucket1["S3 Bucket 1"]:::s3
Bucket2["S3 Bucket 2"]:::s3
Queue1["SQS Queue 1"]:::sqs
Queue2["SQS Queue 2"]:::sqs
Queue3["SQS Queue 3"]:::sqs
Lambda1["Lambda Function 1"]:::lambda
Lambda2["Lambda Function 2"]:::lambda
Lambda3["Lambda Function 3"]:::lambda
IAMRole["IAM Role"]:::iam
IAMPolicy["IAM Policy"]:::iam
%% Connections
Bucket1 -->|"S3 Event: ObjectCreated"| Queue1
Queue1 -->|"Event Source Mapping"| Lambda1
Queue2 -->|"Event Source Mapping"| Lambda2
Queue3 -->|"Event Source Mapping"| Lambda3
Lambda1 -->|"Can send messages to"| Queue2
Lambda2 -->|"Can process messages"| Queue2
IAMRole -->|"Assumed by"| Lambda1
IAMRole -->|"Assumed by"| Lambda2
IAMRole -->|"Assumed by"| Lambda3
IAMPolicy -->|"Attached to"| IAMRole
%% Data flow explanation
User["User/Client\n(Uploads file)"] -->|"PUT Object"| Bucket1
%% subgraph "Data Flow"
%% direction LR
%% UploadFile["1. Upload file to S3"] --> TriggerNotification["2. S3 notification to SQS"] --> InvokeLambda1["3. Lambda1 processes message"] --> SendToQueue2["4. Lambda1 sends message to Queue2"] --> InvokeLambda2["5. Lambda2 processes message"] --> CompleteProcessing["6. Processing complete"]
%% end
Local
The Local environment is the default environment. It is used to test and develop infrastructure locally. It is configured to use the LocalStack endpoint, which allows you to run Terraform commands locally without an internet connection. Here, you can define your infrastructure using the LocalStack provider like follow:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
locals {
environment = "local"
aws_account_id = "000000000000" # LocalStack default
is_localstack = true
}
inputs = {
environment = local.environment
aws_account_id = local.aws_account_id
is_localstack = local.is_localstack
}
generate "provider" {
path = "provider_generated.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
# LocalStack configuration
access_key = "test"
secret_key = "test"
region = "us-east-1"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
s3_use_path_style = true
endpoints {
s3 = "https://localhost.localstack.cloud:4566"
sqs = "https://localhost.localstack.cloud:4566"
lambda = "https://localhost.localstack.cloud:4566"
iam = "https://localhost.localstack.cloud:4566"
}
}
EOF
}
and to add more endpoint types, you can add them to the endpoints
block. The following example adds the Route53, SNS, and StepFunctions endpoints:
1
2
3
4
5
6
7
8
9
10
endpoints {
s3 = "https://localhost.localstack.cloud:4566"
sqs = "https://localhost.localstack.cloud:4566"
lambda = "https://localhost.localstack.cloud:4566"
iam = "https://localhost.localstack.cloud:4566"
route53 = "http://localhost:4566"
stepfunctions = "http://localhost:4566"
sns = "https://localhost.localstack.cloud:4566"
}
Production
When you are ready to move to production, you can simply use the following contents block:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
locals {
environment = "production"
aws_account_id = "PRODUCTION_ACCOUNT_ID" # Replace with actual production account ID
is_localstack = false
}
inputs = {
environment = local.environment
aws_account_id = local.aws_account_id
is_localstack = local.is_localstack
}
generate "provider" {
path = "provider_generated.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
# Production configuration
region = "us-east-1"
}
EOF
}
Testing and Logging
To get things like logs from your lambda functions, you can use the following scripts that I have defined (I am still planning to expand the scope of the test project to include more features):
1
2
3
4
5
6
7
8
# Make the script executable
chmod +x utilities/get_lambda_logs.sh
# View logs for lambda1-local (default if no argument provided)
./utilities/get_lambda_logs.sh
# View logs for a specific lambda (e.g., lambda2-local)
./utilities/get_lambda_logs.sh 2
The lambda logs are easily grabbed from the LocalStack console using:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
# Make script executable with: chmod +x get_lambda_logs.sh
# Check if argument is provided
if [ $# -eq 0 ]; then
# Default to lambda1 if no argument provided
LAMBDA_NUM=1
else
# Use the provided argument
LAMBDA_NUM=$1
fi
# Set the log group name based on the lambda number
LOG_GROUP_NAME="/aws/lambda/lambda${LAMBDA_NUM}-local"
# Get the latest log stream for the specified lambda
LATEST_LOG_STREAM=$(awslocal logs describe-log-streams --log-group-name "$LOG_GROUP_NAME" | jq -r '.logStreams | max_by(.creationTime) | .logStreamName')
# Check if log stream was found
if [ -z "$LATEST_LOG_STREAM" ] || [ "$LATEST_LOG_STREAM" == "null" ]; then
echo "No log streams found for $LOG_GROUP_NAME"
exit 1
fi
echo "Getting logs for lambda${LAMBDA_NUM}-local from stream: $LATEST_LOG_STREAM"
# Get the log events
awslocal logs get-log-events --log-group-name "$LOG_GROUP_NAME" --log-stream-name "$LATEST_LOG_STREAM" | jq
Final Thoughts
This is more than just a template; it’s a complete workflow that simplifies Infrastructure as Code. By embracing this scaffold, teams can speed up their development cycles, reduce costs, and build a more reliable and consistent deployment pipeline from the ground up. The ability to test locally and deploy to production in a controlled manner is a huge benefit for both engineering teams and finance departments. You can find it here: https://github.com/newyork167/LocalStack-Terragrunt-Scaffold.