Running AWS locally with Docker + LocalStack — no account needed

I wanted a throwaway AWS I could break, reset and run offline — no cloud bill, no real credentials. Docker plus LocalStack gets you there in an afternoon. But partway through, a licensing change stopped the whole thing dead. Here's the full setup, the wall I hit, and the options for running it without an account.

The goal

One local environment that behaves enough like AWS to test real SDK code against — S3, DynamoDB, SQS, that sort of thing — without touching a real account. It should start with one command, provision the same resources every time, and be safe to delete and rebuild whenever it gets messy. Crucially, I wanted the same application code to run locally and against real AWS with nothing changed but an environment variable.

Why LocalStack

LocalStack is a single container that emulates the AWS API surface on http://localhost:4566. Point the AWS CLI or an SDK at that endpoint instead of the real one and most of the common services just answer. It's disposable by design, which is exactly what you want for a home lab: spin it up, poke it, tear it down.

The setup, in brief

The moving parts are Docker Desktop (with the WSL2 backend on Windows), a small docker-compose.yml, and a bootstrap script that provisions your resources on every start so the environment is identical each time. Here's the compose file, trimmed to the essentials:

docker-compose.yml

services:
  localstack:
    image: localstack/localstack:4.4.0      # see "the wall" below
    ports:
      - "4566:4566"
    environment:
      - SERVICES=s3,dynamodb,lambda,sqs,sns,stepfunctions,iam,logs,cloudformation
    volumes:
      - ./volume:/var/lib/localstack
      - /var/run/docker.sock:/var/run/docker.sock
      - ./init/ready.d:/etc/localstack/init/ready.d

Anything dropped in init/ready.d runs once LocalStack is ready. A tiny bootstrap script there can create your buckets, tables and queues so they exist the moment the stack is up:

init/ready.d/bootstrap.sh

#!/bin/bash
awslocal s3 mb s3://test-uploads
awslocal s3 mb s3://test-archive
awslocal dynamodb create-table --table-name Items \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST
awslocal sqs create-queue --queue-name jobs
awslocal sns create-topic --name events

The single most common Windows gotcha: that bootstrap script must have Unix (LF) line endings. If your editor saves it with Windows CRLF, the Linux container chokes on it. In VS Code, set "files.eol": "\n" for the workspace and the problem disappears.

The wall: exit code 55

Everything pulled and started, then the container immediately died. The tell was the status:

Terminal

docker ps -a --filter name=localstack-main
# STATUS: Exited (55) about a minute ago

The logs spelled out why:

Localstack returning with exit code 55. Reason:
License activation failed! No credentials were found in the environment.
Please make sure to either set the LOCALSTACK_AUTH_TOKEN variable to a valid auth token.

This isn't a setup mistake — it's a deliberate change. On 23 March 2026 LocalStack consolidated its two Docker images (the free community image and the Pro image) into a single image that requires an account and an auth token, and moved to calendar versioning. The old free-to-run community image was retired under the latest tag. There was a temporary bypass environment variable during the transition, but that expired on 6 April 2026, so it's no help now.

If you followed an older tutorial that says image: localstack/localstack:latest and you're testing this today, exit 55 is exactly what you'll get.

Your options without an account

There are a few ways forward, depending on what you're doing and whether it's for work or play.

Option 1 — Pin to 4.4.0 (the no-account route)

The last community release before the consolidation, localstack/localstack:4.4.0, still runs with no auth token, no signup and no account. It covers all the core services most local dev and testing needs — S3, DynamoDB, SQS, SNS, Lambda, IAM, logs, CloudFormation and more. It's a one-line change:

docker-compose.yml

image: localstack/localstack:4.4.0

Then rebuild cleanly:

docker compose down
docker compose up -d

The trade-off is honest: 4.4.0 is frozen. It gets no updates and no newly-added service emulation. For a local sandbox that you rebuild constantly, that's usually fine — and it sidesteps the licensing question entirely.

Option 2 — The free Hobby tier

LocalStack still offers a permanent free tier for non-commercial use, currently branded "Hobby". It gives you the current image with 30-plus emulated services and VS Code integration. The catches: you have to register and pass an LOCALSTACK_AUTH_TOKEN, and because it's non-commercial-only, it's the wrong choice for anything work-related. Great for personal learning, not for the day job.

Option 3 — moto, if you only need it inside tests

If your goal is unit tests rather than a running endpoint, the moto Python library mocks AWS calls in-process with no Docker and no account at all. It won't give you a localhost:4566 you can hit from the CLI, but for "does my boto3 code do the right thing" it's the lightest option going.

Option 4 — A paid account (only when you actually need Pro services)

The moment you need things that were always Pro-only — RDS, ECS/EKS, Cognito, AppSync, richer IAM enforcement and so on — you need an authenticated account on a paid tier. For a commercial context that's a procurement decision, not a personal signup, since the free tier is non-commercial. Check LocalStack's current pricing if you get to that point.

Short version: for a free, no-account local AWS, pin to localstack/localstack:4.4.0. Reach for the Hobby tier only for non-commercial use, or a paid account only when you genuinely need Pro-only services.

The reusable pattern worth stealing

The bit I'd keep from the whole exercise is how the application code decides where to send its calls. Read the endpoint from an environment variable: if it's set, talk to LocalStack with throwaway credentials; if it isn't, let the SDK fall through to its normal credential chain and talk to real AWS. The same file runs against both.

test_aws.py

import os, boto3

REGION = "eu-west-2"
ENDPOINT = os.environ.get("AWS_ENDPOINT_URL")   # set locally, unset in real AWS

def client(service):
    kwargs = {"region_name": REGION}
    if ENDPOINT:                                 # local: hit LocalStack
        kwargs["endpoint_url"] = ENDPOINT
        kwargs["aws_access_key_id"] = "test"
        kwargs["aws_secret_access_key"] = "test"
    return boto3.client(service, **kwargs)       # unset: real AWS credential chain

In an editor, inject that endpoint per-workspace rather than globally, so only this project points at LocalStack. In VS Code that's a .vscode/settings.json in the project folder:

.vscode/settings.json

{
  "aws.profile": "localstack",
  "terminal.integrated.env.windows": {
    "AWS_ENDPOINT_URL": "http://localhost:4566",
    "AWS_PROFILE": "localstack"
  },
  "files.eol": "\n"
}

Two things have to be true for that injection to take: the workspace has to be trusted (restricted mode ignores terminal env settings), and the terminal has to be opened after the folder — env vars only apply to terminals spawned once the settings are loaded.

The whole setup as one PowerShell script

If you'd rather not do the steps by hand, here's the lot as one idempotent PowerShell script. It assumes WSL2 and Docker Desktop are already installed and the engine is running, then does the rest: scaffolds the project folder, writes the compose / .env / bootstrap / VS Code files (with the LF-endings fix), adds a throwaway localstack CLI profile that never touches your real credentials, installs awslocal and the VS Code extensions, and brings the stack up with an S3 + DynamoDB smoke test. It's pinned to 4.4.0, so it runs with no account. Save it and run powershell -ExecutionPolicy Bypass -File .\Setup-LocalAws.ps1 (add -ProjectPath to place it on another drive).

Setup-LocalAws.ps1

<#
.SYNOPSIS
    Finishes a local AWS testing environment (LocalStack on Docker) in one run.

.DESCRIPTION
    A single, self-contained script for when you already have:
      - WSL2 installed and working
      - Docker Desktop installed, engine running
      - (Optional) Docker's disk image relocated to a drive with room to grow,
        via Settings > Resources > Advanced > Disk image location

    It does NOT touch Docker's data location or restart the engine. It scaffolds
    the project, writes every config file, creates the throwaway 'localstack'
    AWS CLI profile, installs awslocal + the VS Code extensions, then brings
    LocalStack up and smoke-tests S3 + DynamoDB.

    No administrator rights required. Idempotent: safe to re-run.

.PARAMETER ProjectPath
    LocalStack project root. Point it at any drive with space.
    Default: C:\dev\aws-local

.PARAMETER Region
    Default region for the localstack profile. Default: eu-west-2

.PARAMETER Force
    Overwrite project config files if they already exist.

.PARAMETER SkipVerify
    Write everything but do not bring the stack up / smoke test.

.EXAMPLE
    .\Setup-LocalAws.ps1
.EXAMPLE
    .\Setup-LocalAws.ps1 -ProjectPath 'E:\dev\aws-local' -Region 'eu-west-1' -Force
#>

[CmdletBinding()]
param(
    [string]$ProjectPath = 'C:\dev\aws-local',
    [string]$Region      = 'eu-west-2',
    [switch]$Force,
    [switch]$SkipVerify
)

$ErrorActionPreference = 'Stop'

function Write-Step  { param($m) Write-Host "`n=== $m ===" -ForegroundColor Cyan }
function Write-Ok    { param($m) Write-Host "  [OK]   $m" -ForegroundColor Green }
function Write-Info  { param($m) Write-Host "  [..]   $m" -ForegroundColor Gray }
function Write-Warn2 { param($m) Write-Host "  [WARN] $m" -ForegroundColor Yellow }
function Write-Fail  { param($m) Write-Host "  [FAIL] $m" -ForegroundColor Red }

# UTF-8 (no BOM) with forced LF endings. Essential for the bootstrap .sh -
# Windows CRLF breaks it inside the Linux container.
function Write-LfFile {
    param([string]$Path, [string]$Content)
    $lf  = $Content -replace "`r`n", "`n"
    $enc = New-Object System.Text.UTF8Encoding($false)
    [System.IO.File]::WriteAllText($Path, $lf, $enc)
}
function New-Dir { param($p) if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } }

# awslocal if available, else the explicit-endpoint form.
function Invoke-AwsLocal {
    param([string[]]$CmdArgs)
    if (Get-Command awslocal -ErrorAction SilentlyContinue) { & awslocal @CmdArgs }
    else { & aws --profile localstack --endpoint-url=http://localhost:4566 @CmdArgs }
}

# ---------------------------------------------------------------------------

Write-Host @"

  +---------------------------------------------------------------+
  |  LOCAL AWS LAB  -  Finish Setup (single run)                  |
  +---------------------------------------------------------------+
"@ -ForegroundColor White

# --- 0. Preflight ----------------------------------------------------------
Write-Step "Preflight"

if (-not (Test-Path "$($ProjectPath.Substring(0,2))\")) {
    throw "Drive $($ProjectPath.Substring(0,2)) not found. Pass a valid -ProjectPath."
}

if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
    throw "'docker' not found on PATH. Is Docker Desktop installed?"
}

docker info *> $null
if ($LASTEXITCODE -ne 0) {
    Write-Fail "Docker engine is not reachable."
    Write-Warn2 "Open Docker Desktop, wait for the whale icon to read 'Engine running',"
    Write-Warn2 "then re-run this script. (This script will not start it for you.)"
    exit 1
}
Write-Ok "Docker engine reachable."

# --- 1. Scaffold ----------------------------------------------------------
Write-Step "Scaffold project at $ProjectPath"
New-Dir $ProjectPath
New-Dir (Join-Path $ProjectPath 'init\ready.d')
New-Dir (Join-Path $ProjectPath 'volume')
New-Dir (Join-Path $ProjectPath '.vscode')
Write-Ok "Folder structure ready."

function Write-ProjectFile {
    param([string]$Rel, [string]$Body)
    $full = Join-Path $ProjectPath $Rel
    if ((Test-Path $full) -and -not $Force) {
        Write-Warn2 "$Rel exists - kept (use -Force to overwrite)."; return
    }
    Write-LfFile -Path $full -Content $Body
    Write-Ok "Wrote $Rel"
}

$compose = @'
services:
  localstack:
    container_name: localstack-main
    image: localstack/localstack:4.4.0     # pinned: runs with no auth token / no account
    ports:
      - "127.0.0.1:4566:4566"
      - "127.0.0.1:4510-4559:4510-4559"
    environment:
      - DEBUG=${DEBUG:-0}
      - PERSISTENCE=1
      - SERVICES=s3,dynamodb,lambda,sqs,sns,stepfunctions,iam,logs,cloudformation
      - LAMBDA_RUNTIME_EXECUTOR=docker
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
      - "./init/ready.d:/etc/localstack/init/ready.d"
      - "/var/run/docker.sock:/var/run/docker.sock"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
      interval: 10s
      retries: 8
'@

$envFile = @"
# Absolute path keeps LocalStack state wherever you set ProjectPath
LOCALSTACK_VOLUME_DIR=$($ProjectPath -replace '\\','/')/volume
DEBUG=0
"@

$bootstrap = @'
#!/bin/bash
echo "> Provisioning local AWS resources..."

# S3
awslocal s3 mb s3://test-uploads
awslocal s3 mb s3://test-artifacts

# DynamoDB
awslocal dynamodb create-table \
  --table-name Items \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

# SQS + SNS
awslocal sqs create-queue --queue-name work-queue
awslocal sns create-topic --name events-topic

echo "> Bootstrap complete."
'@

$vscode = @"
{
  "aws.profile": "localstack",
  "terminal.integrated.env.windows": {
    "AWS_ENDPOINT_URL": "http://localhost:4566",
    "AWS_PROFILE": "localstack"
  },
  "files.eol": "\n"
}
"@

Write-ProjectFile -Rel 'docker-compose.yml'           -Body $compose
Write-ProjectFile -Rel '.env'                         -Body $envFile
Write-ProjectFile -Rel 'init\ready.d\01-bootstrap.sh' -Body $bootstrap
Write-ProjectFile -Rel '.vscode\settings.json'        -Body $vscode

# --- 2. AWS CLI (install if missing) --------------------------------------
Write-Step "AWS CLI v2"
if (Get-Command aws -ErrorAction SilentlyContinue) {
    Write-Ok "AWS CLI present: $((aws --version) 2>&1)"
} else {
    Write-Info "AWS CLI not found - attempting winget install..."
    if (Get-Command winget -ErrorAction SilentlyContinue) {
        try {
            winget install --exact --id Amazon.AWSCLI `
                --accept-source-agreements --accept-package-agreements --silent
            Write-Ok "AWS CLI installed (open a new terminal later for PATH to refresh)."
        } catch {
            Write-Warn2 "winget install failed. Install manually from https://aws.amazon.com/cli/ then re-run."
        }
    } else {
        Write-Warn2 "winget unavailable. Install AWS CLI v2 from https://aws.amazon.com/cli/ then re-run."
    }
}

# --- 3. localstack profile (never touches real credentials) ---------------
Write-Step "Configure 'localstack' AWS CLI profile"
$awsDir   = Join-Path $env:USERPROFILE '.aws'
New-Dir $awsDir
$credFile = Join-Path $awsDir 'credentials'
$confFile = Join-Path $awsDir 'config'

$credText = if (Test-Path $credFile) { Get-Content $credFile -Raw } else { '' }
if ($credText -notmatch '(?m)^\[localstack\]') {
    Add-Content -Path $credFile -Value "`n[localstack]`naws_access_key_id = test`naws_secret_access_key = test`n"
    Write-Ok "Added [localstack] to ~/.aws/credentials"
} else { Write-Warn2 "[localstack] credential profile already present - kept." }

$confText = if (Test-Path $confFile) { Get-Content $confFile -Raw } else { '' }
if ($confText -notmatch '(?m)^\[profile localstack\]') {
    Add-Content -Path $confFile -Value "`n[profile localstack]`nregion = $Region`noutput = json`n"
    Write-Ok "Added [profile localstack] to ~/.aws/config"
} else { Write-Warn2 "[profile localstack] config already present - kept." }

# --- 4. awslocal wrapper --------------------------------------------------
Write-Step "awslocal wrapper"
if (Get-Command pip -ErrorAction SilentlyContinue) {
    pip install --quiet awscli-local
    Write-Ok "awslocal installed."
} else {
    Write-Warn2 "Python/pip not found - skipping awslocal."
    Write-Warn2 "Use: aws --profile localstack --endpoint-url=http://localhost:4566 ..."
}

# --- 5. VS Code extensions ------------------------------------------------
Write-Step "VS Code extensions"
if (Get-Command code -ErrorAction SilentlyContinue) {
    foreach ($ext in @(
        'ms-azuretools.vscode-docker',
        'amazonwebservices.aws-toolkit-vscode',
        'ms-vscode-remote.remote-wsl')) {
        code --install-extension $ext --force | Out-Null
        Write-Ok "Extension: $ext"
    }
} else {
    Write-Warn2 "'code' not on PATH. In VS Code run 'Shell Command: Install code in PATH', then re-run, or add the extensions manually."
}

# --- 6. Bring it up + smoke test ------------------------------------------
if ($SkipVerify) {
    Write-Step "Skipping verification (-SkipVerify)"
    Write-Info "To start it later:  cd $ProjectPath ; docker compose up -d"
} else {
    Write-Step "Start LocalStack"
    Push-Location $ProjectPath
    docker compose up -d
    if ($LASTEXITCODE -ne 0) { Pop-Location; throw "docker compose up failed." }
    Write-Ok "Compose stack started."

    Write-Step "Wait for health"
    $deadline = (Get-Date).AddMinutes(3); $healthy = $false
    while ((Get-Date) -lt $deadline) {
        try {
            $h = Invoke-RestMethod 'http://localhost:4566/_localstack/health' -TimeoutSec 5
            if ($h.services) { $healthy = $true; break }
        } catch { }
        Write-Info "Booting (first run pulls the image)..."
        Start-Sleep -Seconds 8
    }
    if (-not $healthy) { Pop-Location; throw "LocalStack not healthy in time. Check: docker compose logs localstack" }
    Write-Ok "LocalStack healthy."

    Write-Step "Smoke test - S3"
    $tmp = Join-Path $env:TEMP 'localstack-smoke.txt'
    'hello localstack' | Set-Content -Path $tmp -Encoding ASCII
    Invoke-AwsLocal @('s3','cp',$tmp,'s3://test-uploads/') | Out-Null
    if ((Invoke-AwsLocal @('s3','ls','s3://test-uploads/')) -match 'localstack-smoke.txt') {
        Write-Ok "S3 round-trip succeeded."
    } else { Write-Fail "S3 object not found - check the bootstrap script ran." }

    Write-Step "Smoke test - DynamoDB"
    Invoke-AwsLocal @('dynamodb','put-item','--table-name','Items',
        '--item','{"id":{"S":"001"},"note":{"S":"it works"}}') | Out-Null
    if ((Invoke-AwsLocal @('dynamodb','scan','--table-name','Items') | Out-String) -match 'it works') {
        Write-Ok "DynamoDB write/read succeeded."
    } else { Write-Fail "DynamoDB item not found." }

    Pop-Location
}

Write-Host @"

  +---------------------------------------------------------------+
  |  DONE                                                         |
  +---------------------------------------------------------------+

  Daily use  (from $ProjectPath):
    docker compose up -d        start the local cloud
    docker compose down         stop  (state persists on disk)
    docker compose down -v      stop + wipe for a fresh slate
    awslocal s3 ls              talk to your local AWS

  Endpoint: http://localhost:4566   Profile: localstack

"@ -ForegroundColor White

A runtime-free smoke test

And a quick way to prove the whole loop works from any terminal. This one uses only the AWS CLI, so there's no Python or Node runtime required. Run it from the VS Code terminal and it picks up the injected endpoint automatically; run it from a plain shell and it falls back to the explicit endpoint and profile. It creates its own scratch bucket, table and queue, so it depends on nothing your bootstrap did.

Test-AwsLocal.ps1

<#
.SYNOPSIS
    Self-contained smoke test for the local AWS environment.

.DESCRIPTION
    Exercises S3, DynamoDB and SQS end to end against LocalStack using only the
    AWS CLI (no Python/Node required). Creates its own scratch resources so it
    depends on nothing else, and cleans the S3 object up afterwards.

    Works either way:
      - From the VS Code integrated terminal (with the workspace .vscode
        settings) it uses the injected AWS_ENDPOINT_URL / AWS_PROFILE.
      - From a plain shell it falls back to the explicit endpoint + profile.

.NOTES
    Requires: LocalStack running (from your project folder) and AWS CLI v2.
#>

[CmdletBinding()]
param(
    [string]$Endpoint = 'http://localhost:4566',
    [string]$Profile  = 'localstack'
)

$ErrorActionPreference = 'Stop'

function Write-Step { param($m) Write-Host "`n=== $m ===" -ForegroundColor Cyan }
function Write-Ok   { param($m) Write-Host "  [PASS] $m"   -ForegroundColor Green }
function Write-Fail { param($m) Write-Host "  [FAIL] $m"   -ForegroundColor Red }
function Write-Info { param($m) Write-Host "  [..]   $m"   -ForegroundColor Gray }

# Use injected env if present (VS Code terminal), else explicit flags.
$usingEnv = [bool]$env:AWS_ENDPOINT_URL
function Invoke-Aws {
    param([string[]]$CmdArgs)
    if ($usingEnv) { & aws @CmdArgs }
    else           { & aws --profile $Profile --endpoint-url=$Endpoint @CmdArgs }
}

$bucket = 'localstack-smoke-bucket'
$table  = 'SmokeTest'
$queue  = 'smoke-queue'
$pass   = 0
$fail   = 0
function Tally { param([bool]$ok,[string]$msg) if ($ok) { Write-Ok $msg; $script:pass++ } else { Write-Fail $msg; $script:fail++ } }

Write-Host @"

  +---------------------------------------------------------------+
  |  Local AWS  -  Service Smoke Test                             |
  +---------------------------------------------------------------+
"@ -ForegroundColor White

Write-Info ("Mode: {0}" -f ($(if ($usingEnv) { 'injected env (AWS_ENDPOINT_URL set)' } else { 'explicit endpoint fallback' })))

# --- 0. Reachability -------------------------------------------------------
Write-Step "LocalStack reachable"
try {
    $h = Invoke-RestMethod "$Endpoint/_localstack/health" -TimeoutSec 5
    Tally ([bool]$h.services) "Health endpoint responded"
} catch {
    Write-Fail "Cannot reach $Endpoint - is LocalStack running? (from your project folder: docker compose up -d)"
    exit 1
}

# --- 1. S3 round trip ------------------------------------------------------
Write-Step "S3"
Invoke-Aws @('s3api','create-bucket','--bucket',$bucket) *> $null
$tmp = Join-Path $env:TEMP 'localstack-s3-test.txt'
"hello local aws $(Get-Date -Format o)" | Set-Content $tmp -Encoding ascii
Invoke-Aws @('s3','cp',$tmp,"s3://$bucket/") *> $null
$ls = Invoke-Aws @('s3','ls',"s3://$bucket/")
Tally ([bool]($ls -match 'localstack-s3-test.txt')) "Object uploaded and listed"
$back = Join-Path $env:TEMP 'localstack-s3-back.txt'
Invoke-Aws @('s3','cp',"s3://$bucket/localstack-s3-test.txt",$back) *> $null
Tally ((Test-Path $back) -and ((Get-Content $back -Raw) -match 'hello local aws')) "Object downloaded with correct content"
Invoke-Aws @('s3','rm',"s3://$bucket/localstack-s3-test.txt") *> $null

# --- 2. DynamoDB put / get -------------------------------------------------
Write-Step "DynamoDB"
$exists = $false
try { Invoke-Aws @('dynamodb','describe-table','--table-name',$table) *> $null; $exists = ($LASTEXITCODE -eq 0) } catch {}
if (-not $exists) {
    Invoke-Aws @('dynamodb','create-table','--table-name',$table,
        '--attribute-definitions','AttributeName=id,AttributeType=S',
        '--key-schema','AttributeName=id,KeyType=HASH',
        '--billing-mode','PAY_PER_REQUEST') *> $null
    Start-Sleep -Seconds 2
}
$item = Join-Path $env:TEMP 'localstack-ddb-item.json'
'{"id":{"S":"smoke-001"},"source":{"S":"local-aws-smoke-test"}}' | Set-Content $item -Encoding ascii
Invoke-Aws @('dynamodb','put-item','--table-name',$table,'--item',"file://$item") *> $null
$got = Invoke-Aws @('dynamodb','get-item','--table-name',$table,'--key','{"id":{"S":"smoke-001"}}') | Out-String
Tally ([bool]($got -match 'local-aws-smoke-test')) "Item written and read back"

# --- 3. SQS send / receive -------------------------------------------------
Write-Step "SQS"
$qurl = (Invoke-Aws @('sqs','create-queue','--queue-name',$queue) | ConvertFrom-Json).QueueUrl
Invoke-Aws @('sqs','send-message','--queue-url',$qurl,'--message-body','smoke test message') *> $null
$msg = Invoke-Aws @('sqs','receive-message','--queue-url',$qurl) | Out-String
Tally ([bool]($msg -match 'smoke test message')) "Message sent and received"

# --- Summary ---------------------------------------------------------------
Write-Host "`n  ---------------------------------------------------------------" -ForegroundColor White
if ($fail -eq 0) {
    Write-Host "  RESULT: ALL $pass CHECKS PASSED" -ForegroundColor Green
    Write-Host "  Docker <-> LocalStack is fully working." -ForegroundColor Green
} else {
    Write-Host "  RESULT: $pass passed, $fail FAILED" -ForegroundColor Red
    Write-Host "  See the [FAIL] lines above." -ForegroundColor Red
}
Write-Host ""

Other gotchas that ate time

A few smaller traps, in case they save you the same half hour:

Placeholders in copied config. If you paste a terminal profile or config snippet from a tutorial, scan it for placeholders like YOUR_LINUX_USER before saving. A literal placeholder in a start-up path fails before the shell even opens, with an error that looks far scarier than the cause.

Where Docker's disk actually lives. Docker Desktop's WSL2 backend keeps everything in one virtual disk that only grows. If your system drive is tight, move it via Settings → Resources → Advanced → Disk image location and restart — the GUI is the source of truth. Note that docker info reports a path inside the VM (/var/lib/docker), which is not where the disk sits on your machine, so don't be misled by it.

Don't interrupt python -m venv. If you paste create-venv, install and run as one block, an early Enter can cut off venv while it's still setting up pip — leaving a virtual environment with no working pip. Run the create step on its own, gate on .venv\Scripts\python.exe -m pip --version, then install. Calling python -m pip and the venv's python.exe directly also dodges the pip-not-on-PATH and execution-policy prompts in one go.

Takeaways

A disposable local AWS is genuinely a one-afternoon job, and the endpoint-from-env pattern means you write the code once. The catch in 2026 is licensing, not tooling: if you want it free and account-free, pin the image to 4.4.0 and accept that it's frozen. Everything past that — newer emulation or Pro-only services — is a deliberate account decision, and for commercial use, a licensing one.

A personal home-lab experiment, shared as-is and not affiliated with Amazon Web Services or LocalStack. Service names, versions and pricing change — check the current LocalStack and AWS docs before relying on any detail here, and tear down test resources to avoid charges.