Reducing Client AWS Costs by 70% with LocalStack

As a cloud consulting company, we’ve seen our share of AWS bills. But when our client forwarded us their monthly statement with a concerned email, even we were surprised.

$3,247 for the month.

The breakdown was what really caught our attention:

  • Production: $1,400
  • Development & Staging: $1,847

They were literally spending more on building the product than running it in production.

The client, a fast-growing SaaS company, had approached us to optimize their AWS costs. We expected to find inefficiencies in production environments—oversized instances, unused resources, that sort of thing. Instead, we discovered something completely different.

Development was the real problem

Six months earlier, the team had just 3 developers, a serverless AWS architecture, and manageable cloud bills. Nothing unusual. Then the team grew to 8 developers, and things got expensive fast.

After a junior developer accidentally deleted a shared AWS resource (we’ve all been there), the team made what seemed like a reasonable decision: “Each developer should have their own AWS environment.”

In practice, this meant:

  • 8 developers meant 8 full AWS stacks running 24/7.
  • Duplicated Lambda functions, DynamoDB tables, S3 buckets across all environments.
  • $780 per month just for developer environments.

To their credit, they followed AWS best practices almost perfectly. Integration tests on every pull request, temporary AWS environments spun up per PR, tests running against real AWS services.

Their monthly CI/CD costs:

  • ECS Fargate containers: $300
  • RDS test databases: $60
  • CloudFormation operations: $80
  • Data transfer: $42
  • Miscellaneous charges: $42
  • Total: $524 per month.

Like most growing teams, they also had the usual suspects: EC2 instances left running after tests, an Elasticsearch domain from “that one experiment” three months ago, orphaned load balancers nobody remembered creating. Quietly adding another $145 per month.

The productivity hit was worse than the costs

We shadowed the team for a day to understand their workflow. Here’s what a typical bug-fix cycle looked like:

  1. Write code (3 minutes)
  2. Deploy to AWS (3 minutes)
  3. Wait for deployment (5+ minutes)
  4. Test (2 minute)
  5. Check CloudWatch logs (1 minute)
  6. Find another issue
  7. Repeat 5-8 times per bug

Developers spent roughly 60% of their time just waiting. The irony wasn’t lost on us. The cloud was meant to accelerate development, but it was actually slowing them down.

Every meaningful change required a pull request. Every pull request required review. Every review took time. While waiting, AWS resources kept running, test executions kept consuming credits, and repeated experiments became expensive.

As experimentation increased, so did the friction:

  • Spinning up AWS resources for every test iteration.
  • Paying for services even when nothing productive was happening.
  • Slow feedback loops while waiting for cloud deployments.
  • Risk of breaking shared dev environments.
  • Reluctance to experiment freely because cleanup was never trivial.

There was no local debugging. Period. So developers relied on console.log() driven development. Deploy, wait, check logs, repeat. 30-60 second delays before logs appeared in CloudWatch. No breakpoints, no variable inspection, no stepping through code.

"I spend more time waiting for deployments than actually coding." — Senior Engineer

Enter LocalStack

While reviewing their infrastructure, we evaluated several local AWS emulation options and landed on LocalStack. It sounded too good to be true: a complete AWS-like environment running locally.

LocalStack is a local cloud emulator that runs in a Docker container on your machine. From the application’s point of view, nothing changes: Same AWS SDKs, same CLI commands, same APIs, and same infrastructure patterns. The only difference is the endpoint URL.

From here, you have options on how to get started, diving deeper into using LocalStack, and so on.

Select the My License option on the sidebar. It should reveal the following:

For this demo, we will stick with the free-trial version, but feel free to select and read about others if you like.

After selecting the free-trial option, you will need to fill out the remaining personal information to proceed. Once all that is complete, navigate back to the dashboard and select the Auth Tokens selection on the sidebar.

It should reveal the following:

This page generates a personal authentication token for you. You also have the option of resetting it anytime for security reasons.

You will need to add this as an environment variable locally in order to work with the LocalStack API.

Head over to the terminal and paste in the command as is:

Whenever we make calls using the CLI, we are in fact, making an API call to LocalStack behind the scenes and we will be authenticated using this token.

Your freedom of calls depends on your subscription (free-trial/paid). For more advanced features, you will need a paid subscription.

This concludes setting up the LocalStack account. Now, it is on with local installation.

Why This Changed Everything

Once we integrated LocalStack into our workflow, the impact was immediate and measurable.

  • Task processing became nearly 60% faster
  • Infrastructure spin-up time dropped to seconds
  • AWS costs during development were almost eliminated
  • Developers experimented more freely
  • PR review cycles shortened because issues were caught earlier

Most importantly, feedback became instant.

How it actually works

LocalStack runs as a single Docker container exposing AWS-style endpoints, typically on http://localhost:4566.

  1. The request hits LocalStack instead of AWS.
  2. LocalStack routes it to the correct service emulator.
  3. The emulator processes the request.
  4. A response identical to AWS is returned.

State is preserved. If you create an S3 bucket, it exists until you delete it. You can even configure persistence so the state survives container restarts.

The networking piece

LocalStack emulates AWS networking components (VPCs, subnets, route tables) at the API and state level. This allows developers to validate networking architecture and IAM policies without provisioning real VPC networking.

Example Terraform validation:

Terraform

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
  tags = {
    Name = "local-vpc"
  }
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
  tags = {
    Name = "public-subnet"
  }
}

resource "aws_security_group" "web_sg" {
  name   = "web-sg"
  vpc_id = aws_vpc.main.id
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

What LocalStack can’t do

Networking constructs are simulated rather than enforced at the packet level. It is not suitable for:

  • Performance benchmarking.
  • Latency-sensitive networking tests.
  • Validating real traffic flow between isolated subnets.
  • Load testing at scale.

In practice, LocalStack excels at IaC validation, CI/CD testing, IAM policy verification, and catching configuration errors early.


Building a Serverless App with LocalStack ( Demo)

Installing LocalStack

pip install localstack
pip install awscli-local

Starting LocalStack

localstack start -d
localstack status

Testing LocalStack

# Set up environment variables
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1

# Create and list an S3 bucket
awslocal s3 mb s3://my-test-bucket
awslocal s3 ls

Step 1: ECS on LocalStack Demo

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  access_key                  = "test"
  secret_key                  = "test"
  region                      = "us-east-1"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    ec2 = "http://localhost:4566"
    ecs = "http://localhost:4566"
    ecr = "http://localhost:4566"
    iam = "http://localhost:4566"
    logs = "http://localhost:4566"
  }
}

Step 2: Create main.tf

# ECS Cluster
resource "aws_ecs_cluster" "demo_cluster" {
  name = "demo-cluster"

  tags = {
    Environment = "demo"
    ManagedBy   = "terraform"
  }
}

# IAM Role for ECS Task Execution
resource "aws_iam_role" "ecs_task_execution_role" {
  name = "ecs-task-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

# IAM Role for ECS Task
resource "aws_iam_role" "ecs_task_role" {
  name = "ecs-task-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "ecs_logs" {
  name              = "/ecs/demo-app"
  retention_in_days = 7
}

# ECS Task Definition
resource "aws_ecs_task_definition" "demo_task" {
  family                   = "demo-task"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn
  task_role_arn            = aws_iam_role.ecs_task_role.arn

  container_definitions = jsonencode([
    {
      name      = "demo-container"
      image     = "nginx:latest"
      essential = true

      portMappings = [
        {
          containerPort = 80
          hostPort      = 80
          protocol      = "tcp"
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.ecs_logs.name
          "awslogs-region"        = "us-east-1"
          "awslogs-stream-prefix" = "demo"
        }
      }

      environment = [
        {
          name  = "ENV"
          value = "demo"
        }
      ]
    }
  ])
}

# Default VPC (LocalStack creates this automatically)
data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

# Security Group
resource "aws_security_group" "ecs_tasks" {
  name        = "ecs-tasks-sg"
  description = "Allow inbound traffic for ECS tasks"
  vpc_id      = data.aws_vpc.default.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "ecs-tasks-sg"
  }
}

# ECS Service
resource "aws_ecs_service" "demo_service" {
  name            = "demo-service"
  cluster         = aws_ecs_cluster.demo_cluster.id
  task_definition = aws_ecs_task_definition.demo_task.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = data.aws_subnets.default.ids
    security_groups  = [aws_security_group.ecs_tasks.id]
    assign_public_ip = true
  }

  tags = {
    Environment = "demo"
  }
}

Step 3: Create outputs.tf

output "cluster_name" {
  description = "Name of the ECS cluster"
  value       = aws_ecs_cluster.demo_cluster.name
}

output "cluster_arn" {
  description = "ARN of the ECS cluster"
  value       = aws_ecs_cluster.demo_cluster.arn
}

output "service_name" {
  description = "Name of the ECS service"
  value       = aws_ecs_service.demo_service.name
}

output "task_definition_arn" {
  description = "ARN of the task definition"
  value       = aws_ecs_task_definition.demo_task.arn
}

output "security_group_id" {
  description = "ID of the security group"
  value       = aws_security_group.ecs_tasks.id
}

Step 4: Deploy and Test

terraform init
terraform plan
terraform apply -auto-approve

Three Months Later: The Numbers

Cost reduction: 70%

  • Before: $1,847 per month
  • After: $550 per month (LocalStack Base licenses + minimal shared AWS staging)
  • Annual savings: $15,600

Development speed: 5× faster feedback

  • Bug fixes: 62% faster
  • Feature delivery: 33% faster
  • Deploy/test cycle: 54× faster
  • Features shipped per sprint: up 50%

Quality improvements

  • Bugs caught before production: jumped from 77% to 94%
  • Production incidents: down 40%
  • Bug-related support tickets: down 35%

Time saved

Roughly 300 developer hours saved per month, or about $18,000 per month in reclaimed productivity.


Challenges and Solutions

  • Service parity? LocalStack Pro and a minimal staging environment for edge cases.
  • High memory? Enable only needed services (e.g., export SERVICES=s3,lambda). Allocate 4GB RAM to Docker.
  • Accidental AWS calls? Use .env.local defaults.
  • Resources not found? Ensure export AWS_DEFAULT_REGION=us-east-1 is set.
  • Won’t start? Run docker ps to check port 4566.

Essential Commands & Best Practices

Daily Management:

  • localstack start -d / localstack stop
  • localstack logs
  • awslocal s3 ls / awslocal lambda list-functions

Best Practices:

  1. Multi-environment code: Use dynamic blocks in Terraform to toggle LocalStack endpoints.
  2. Persistence: Always use PERSISTENCE=1 and volumes.
  3. Staging environment: Keep a real AWS staging for final integration checks.

The Verdict: When this makes sense

Ideal for:

  • 3+ developers on AWS infra.
  • Serverless or microservices architecture.
  • AWS dev costs over $500/month.
  • Integration tests in CI/CD.

Not ideal for:

  • Very small teams (1-2 devs).
  • Highly specialized AWS workloads not yet supported.

Looking Back

We went looking for cost optimizations. What we found instead was a broken development workflow. LocalStack didn’t just reduce costs—it fundamentally changed how the team worked.

Start today. Install LocalStack. Build something. Break it. Fix it. Learn. All without spending a cent on AWS infrastructure.

Resources: