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

# Three pip packages, one command. What each one gives you:
#   localstack       - the CLI that starts and stops the mock-cloud container
#   awscli-local     - installs `awslocal`, an `aws` wrapper pre-pointed at LocalStack
#   terraform-local  - installs `tflocal`, the same idea for Terraform
python3 -m pip install --user localstack awscli-local terraform-local   # --user = no sudo; installs into your home dir

# Confirm both tools are on your PATH before continuing
localstack --version    # prints the CLI version; the Docker image itself is pulled later, on first start
awslocal --version      # prints the underlying aws-cli version that awslocal wraps

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          # start the mock cloud; -d = detached, so it runs in the background and frees your shell
localstack status services   # print a table of every emulated service (s3, iam, lambda, ...) and whether each is 'available'

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

# LocalStack never checks credentials, but the AWS CLI refuses to run without *some* value set.
# These three exports satisfy that requirement; the literal string "test" is the community convention.
export AWS_ACCESS_KEY_ID=test          # any non-empty value works
export AWS_SECRET_ACCESS_KEY=test      # never a real secret - this is a throwaway mock
export AWS_DEFAULT_REGION=us-east-1    # a region still has to be set; the CLI builds its endpoints from it

# Your first API call. Real AWS returns your 12-digit account id here; LocalStack
# always returns the canned 000000000000 - that is your proof you are hitting the mock.
awslocal sts get-caller-identity

awslocal s3 mb s3://lab-bucket               # mb = "make bucket"
echo "top secret" > secret.txt               # create a throwaway local file to upload
awslocal s3 cp secret.txt s3://lab-bucket/   # cp = copy the local file up into the bucket
awslocal s3 ls s3://lab-bucket/              # ls = list the bucket's contents; you should see secret.txt

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   # -p = no error if it exists; && = only cd in if mkdir succeeded

main.tf:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"   # pull the official AWS provider from the Terraform registry
      version = "~> 5.0"          # any 5.x release, never 6.0 - pins the API surface so upgrades don't surprise you
    }
  }
}

provider "aws" {
  region     = "us-east-1"
  access_key = "test"             # dummy creds again - LocalStack ignores them, but the provider wants values
  secret_key = "test"
  # The three "skip" flags below turn off the provider's start-up calls to the *real*
  # AWS STS and metadata endpoints. Without them, Terraform tries to reach AWS to
  # validate the fake credentials and hangs. They also make this file safe if you ever
  # run plain `terraform` out of habit instead of `tflocal`.
  skip_credentials_validation = true   # don't verify the access key against real STS
  skip_metadata_api_check     = true   # don't probe the EC2 instance-metadata endpoint
  skip_requesting_account_id  = true   # don't call sts:GetCallerIdentity to learn the account id
}
tflocal init    # download the AWS provider plugin into .terraform/ (run once per project, or after changing versions)
tflocal plan    # dry run: print exactly what Terraform *would* create, without touching anything yet

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"        # the bucket's globally-unique name
}

# By default AWS blocks all public access. Flipping every guard to false is what
# actually *lets* the public bucket policy below take effect - this block is the
# real misconfiguration, not the policy on its own.
resource "aws_s3_bucket_public_access_block" "public_data" {
  bucket                  = aws_s3_bucket.public_data.id   # reference the bucket declared above
  block_public_acls       = false   # allow public ACLs to be set
  block_public_policy     = false   # allow a public bucket *policy* (the one defined next)
  ignore_public_acls      = false   # honor public ACLs instead of silently ignoring them
  restrict_public_buckets = false   # don't restrict anonymous / cross-account access
}

# The bucket policy that grants the entire internet read access to every object.
resource "aws_s3_bucket_policy" "public_data" {
  bucket = aws_s3_bucket.public_data.id
  policy = jsonencode({                 # jsonencode turns this HCL map into the JSON AWS expects
    Version = "2012-10-17",
    Statement = [{
      Sid       = "PublicRead",
      Effect    = "Allow",
      Principal = "*",                  # "*" = anyone, no credentials required  <- the dangerous part
      Action    = "s3:GetObject",       # the one action allowed: download (GET) an object
      Resource  = "${aws_s3_bucket.public_data.arn}/*"   # /* = every object in the bucket
    }]
  })
}

# 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        # attach this inline policy directly to the user above
  policy = jsonencode({
    Version   = "2012-10-17",
    # Action "*" on Resource "*" = permission to do anything to everything: full admin.
    Statement = [{ Effect = "Allow", Action = "*", Resource = "*" }]
  })
}

# A static access key for that user. Real keys like this, committed to a git repo,
# are the single most common root cause of cloud breaches.
resource "aws_iam_access_key" "app" {
  user = aws_iam_user.app.name
}
tflocal apply -auto-approve   # actually create the resources; -auto-approve skips the interactive "yes" prompt

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

# Stage a fake "loot" file in the public bucket...
echo "aws_secret_access_key=AKIAFAKE..." > creds.txt      # a decoy secret, not a real key
awslocal s3 cp creds.txt s3://customer-exports-lab/
# ...then fetch it over plain HTTP with NO credentials. The request succeeding is the whole point:
# a public bucket policy means the object is reachable by anyone on the internet.
curl http://localhost:4566/customer-exports-lab/creds.txt

# Now walk the IAM enumeration an attacker runs right after finding the leaked key:
awslocal iam list-users                                             # who exists in this account?
awslocal iam list-access-keys --user-name legacy-app-user          # what keys does this user have?
awslocal iam list-user-policies --user-name legacy-app-user        # what inline policies are attached to it?
awslocal iam get-user-policy --user-name legacy-app-user --policy-name app-admin   # read the policy - it's full 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 - a policy-as-code scanner that reads Terraform statically (no deploy, no cloud, no cost)
python3 -m pip install --user checkov
checkov -d .        # -d = scan every .tf file in this directory; prints a PASS/FAIL line per check

# Trivy in config mode does the same job and also covers Dockerfiles, Kubernetes manifests, and more
#   install it first: brew install trivy   (or see aquasecurity.github.io/trivy)
trivy config .      # scan the current directory's infrastructure-as-code for the same misconfigurations

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   # delete everything Terraform created; -auto-approve skips the confirmation prompt
localstack stop                 # stop the container - community-edition state lives in RAM, so this wipes it clean

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