Cloud Security Office Hours Banner

Free AWS lab with LocalStack

Build a real AWS-shaped lab on your laptop for exactly $0. Deploy S3, IAM, and Lambda with Terraform against a mock cloud, break it on purpose, and scan it - no credit card, no bill, ever.

Jump to the build Back to Home Lab

· · Vendor-neutral

Time: ~2 hours  ·  Difficulty: Beginner  ·  Stack: Docker · LocalStack · Terraform · AWS CLI · Checkov

LocalStack runs a mock of the AWS cloud as a single container on your laptop. Every aws command you already know works against it - you just point the CLI at http://localhost:4566 instead of the real AWS endpoints. Nothing you do here can ever generate a bill, which makes it the perfect first lab: create, mangle, and destroy S3 buckets, IAM roles, and Lambda functions as fast as you can type, with zero risk. This walkthrough takes you from an empty laptop to a deliberately-misconfigured "cloud" you can scan and fix.

Read this before you start. LocalStack emulates the AWS API surface, not AWS's security enforcement. The free Community edition happily lets you create IAM users and attach policies, but it does not actually enforce them - a Deny will not stop anything. That makes LocalStack unbeatable for learning the AWS API, CLI, SDKs, Terraform, and IaC scanning, and the wrong tool for testing whether an IAM policy actually blocks an action. For that, use a real free-tier account (see the break-it-then-catch-it walkthrough).

On this page

  1. What you will build
  2. Prerequisites
  3. Step-by-step
  4. What LocalStack does and does not teach
  5. Common mistakes
  6. Where next

What you will build

Prerequisites

Step-by-step

1. Install LocalStack and the CLI wrappers

# The LocalStack CLI plus the thin awslocal / tflocal wrappers
python3 -m pip install --user localstack awscli-local terraform-local

# confirm
localstack --version
awslocal --version

awslocal is a one-line wrapper that calls the real aws CLI with --endpoint-url=http://localhost:4566 injected, so you never have to type the endpoint. tflocal does the same for Terraform: it auto-points every AWS provider at LocalStack. Both call the tools you already have, so you can always fall back to aws --endpoint-url=http://localhost:4566 … directly.

2. Start LocalStack

localstack start -d          # -d = detached (runs in the background)
localstack status services   # list which service mocks are up

You will see a table of services (s3, iam, lambda, sqs, …) marked available. LocalStack pulls its Docker image on first run, so give it a minute. Tail logs with localstack logs -f; stop it later with localstack stop.

3. Your first calls against the mock cloud

# dummy credentials are fine - LocalStack accepts anything
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1

# who am I? LocalStack returns the canned account 000000000000
awslocal sts get-caller-identity

# create a bucket, upload an object, list it back
awslocal s3 mb s3://lab-bucket
echo "top secret" > secret.txt
awslocal s3 cp secret.txt s3://lab-bucket/
awslocal s3 ls s3://lab-bucket/

Every command is identical to real AWS; only the endpoint differs. Getting 000000000000 back from get-caller-identity is your proof you are hitting LocalStack and not a real account - check it any time you are unsure.

4. Deploy infrastructure with Terraform

Create a working directory and a provider file. tflocal wires the endpoints for you:

mkdir -p ~/localstack-lab && cd ~/localstack-lab

main.tf:

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

provider "aws" {
  region     = "us-east-1"
  access_key = "test"
  secret_key = "test"
  # Belt-and-suspenders: these let the config work even if you accidentally
  # run plain `terraform` instead of `tflocal`.
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true
}
tflocal init
tflocal plan

5. Build a deliberately-vulnerable stack

Now add resources that mirror the classic real-world misconfigurations. Append to main.tf:

# 1) A bucket that is public to the world - the classic data-leak setup
resource "aws_s3_bucket" "public_data" {
  bucket = "customer-exports-lab"
}

resource "aws_s3_bucket_public_access_block" "public_data" {
  bucket                  = aws_s3_bucket.public_data.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_policy" "public_data" {
  bucket = aws_s3_bucket.public_data.id
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Sid       = "PublicRead",
      Effect    = "Allow",
      Principal = "*",
      Action    = "s3:GetObject",
      Resource  = "${aws_s3_bucket.public_data.arn}/*"
    }]
  })
}

# 2) An IAM user with an inline admin policy and a long-lived access key
resource "aws_iam_user" "app" {
  name = "legacy-app-user"
}

resource "aws_iam_user_policy" "app_admin" {
  name   = "app-admin"
  user   = aws_iam_user.app.name
  policy = jsonencode({
    Version   = "2012-10-17",
    Statement = [{ Effect = "Allow", Action = "*", Resource = "*" }]
  })
}

resource "aws_iam_access_key" "app" {
  user = aws_iam_user.app.name
}
tflocal apply -auto-approve

Everything you just wrote is a real finding you will meet in production: a bucket policy with Principal: "*", an access block that blocks nothing, a wildcard Action: "*" IAM policy, and a static access key that never rotates. LocalStack Community will happily create all of it (it just will not enforce the IAM half) - which is exactly what you want for practising the build-and-scan loop.

6. Poke at what you built

# Upload a fake "loot" file, then fetch it with NO credentials at all
echo "aws_secret_access_key=AKIAFAKE..." > creds.txt
awslocal s3 cp creds.txt s3://customer-exports-lab/
curl http://localhost:4566/customer-exports-lab/creds.txt

# Enumerate IAM the way an attacker with the leaked key would
awslocal iam list-users
awslocal iam list-access-keys --user-name legacy-app-user
awslocal iam list-user-policies --user-name legacy-app-user
awslocal iam get-user-policy --user-name legacy-app-user --policy-name app-admin

That anonymous curl succeeding is the whole point: a public bucket policy means the object is on the internet. The iam list-* / get-user-policy sequence is the enumeration muscle memory you will use for real in the break-it-then-catch-it walkthrough.

7. Catch the issues with IaC scanning

This is where a local lab genuinely shines: static scanners find every one of these problems from the Terraform files alone - no deploy, no cloud, no cost.

# Checkov - policy-as-code scanner for Terraform
python3 -m pip install --user checkov
checkov -d .

# Trivy in config mode does the same and also covers Dockerfiles and k8s
#   install: brew install trivy   (or see aquasecurity.github.io/trivy)
trivy config .

Read the output. Checkov flags CKV_AWS_* IDs for the public bucket, the wildcard IAM policy, the missing default encryption, and more. Map each finding back to the exact resource you wrote, fix them one at a time, and re-scan until the run is clean. That loop - write, scan, remediate, re-scan - is precisely what an AppSec / IaC engineer does on every pull request. See the CI/CD security page for wiring the same scan into a pipeline.

8. Tear it down

tflocal destroy -auto-approve   # remove the stack
localstack stop                 # stop the container; state is ephemeral

Community-edition state lives in memory, so stopping the container wipes everything. That is a feature: every session starts from a clean slate.

What LocalStack does and does not teach

When you outgrow the mock - when you need to know whether a policy actually blocks something, or whether a detection fires - move to a real free-tier account with the guardrails from the home-lab guide.

Common mistakes

Where next