Post
Available in: Português

LocalStack Is Now More Expensive Than Testing on AWS

Hey everyone!

On March 23, 2026 LocalStack discontinued the Community Edition. The Docker image everyone was using now requires an account and an authentication token. The free tier was restricted to non-commercial use only.

LocalStack’s pitch has always been the same: run locally, avoid AWS costs. That makes sense. But now that the tool has a price tag, it is worth doing the actual math.

The result is surprising.


What LocalStack charges now

Three paid plans:

PlanPrice per developer/month
Base$39
Ultimate$89
Enterprisecontact sales

A team of five on the Base plan pays $195 per month, or $2,340 per year. On Ultimate, $445 per month.

The free plan remains, but with significant restrictions: non-commercial use only, no CI support, and now requires an account and LOCALSTACK_AUTH_TOKEN even to start the container. What was once a docker run became a signup process.


What AWS offers for free

The AWS free tier has two categories. The first is a 12-month trial for new accounts. The second, and more important, is the always-free tier: no expiration date, available to any account, forever.

The always-free numbers for the most common services used in testing:

ServiceAlways-free tier
Lambda1 million invocations per month
Lambda400,000 GB-seconds per month
DynamoDB25 GB of storage
DynamoDB200 million requests per month
SQS1 million requests per month
S35 GB of storage

An entire team of five developers running integration tests all day will barely scratch those limits.


The actual numbers

Let’s simulate a team of five developers with a CI pipeline that runs on every pull request.

Assume a project with 200 integration tests, 20 PRs per day, 20 working days per month. That is 80,000 test runs per month. Each test invokes Lambda once and makes three DynamoDB operations.

On AWS:

ResourceMonthly usageCost
Lambda invocations80,000$0 (within free tier)
DynamoDB requests240,000$0 (within free tier)
SQS requests80,000$0 (within free tier)
Total $0

On LocalStack (Base plan, 5 devs):

ItemMonthly cost
5 licenses × $39$195
Total$195

The team is paying $195 per month to avoid a cost that would be zero.


When usage goes past the free tier

The AWS free tier has limits. If your test volume is very high, you will start paying.

AWS cut DynamoDB pricing by 50% in November 2024. Current prices outside the free tier:

ServicePrice
Lambda$0.20 per 1 million invocations
DynamoDB reads$0.25 per 1 million requests
DynamoDB writes$1.25 per 1 million requests
SQS$0.40 per 1 million requests

To exceed the Lambda free tier you need more than 1 million invocations in a month. For a team of five that is 200,000 invocations per person per month, or 10,000 per working day per person.

Even in that extreme scenario, the AWS cost would be cents. Still below LocalStack’s $195.


Where LocalStack still makes sense

Being honest here matters. There are real cases where LocalStack delivers genuine value.

Offline development. If you work without internet or in restricted network environments, testing on AWS is not an option. LocalStack solves that.

Services outside the free tier. API Gateway, for example, has a 12-month trial but no always-free tier. If your project relies heavily on those services, AWS costs can add up.

Local iteration speed. Round trips to AWS add latency. For tight development loops where you want feedback in milliseconds, running locally is faster.

Environments without an AWS account. Some corporate contexts make it hard to create accounts or grant AWS access to every developer. LocalStack works around that.

These are legitimate arguments. The problem is when LocalStack gets used out of habit, without anyone running the actual numbers.


MiniStack: the alternative that appeared within days

When the pricing change was announced, MiniStack emerged within days as a direct response. MIT license, no registration, no token, no telemetry.

MiniStack benchmark vs LocalStack

The startup numbers are significant:

ToolStartupIdle RAMDocker image
LocalStack15-30s~500MB~1GB
MiniStack~2s~30MB~150MB

MiniStack covers 35 AWS services in a single container on port 4566, the same pattern as LocalStack. The migration is drop-in: where you had localstack/localstack, you put nahuelnucera/ministack.

Services covered: S3, SQS, SNS, DynamoDB, Lambda, IAM, STS, Secrets Manager, CloudWatch Logs, CloudWatch Metrics, SSM Parameter Store, EventBridge, Kinesis, Step Functions, API Gateway v1 and v2, Cognito, RDS, ElastiCache and ECS.


Using MiniStack manually

Start the container:

1
docker run -p 4566:4566 nahuelnucera/ministack

Any credentials are accepted. Use test by convention:

1
2
3
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1

Create an SQS queue:

1
aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name orders

Create a DynamoDB table:

1
2
3
4
5
aws --endpoint-url=http://localhost:4566 dynamodb create-table \
  --table-name orders \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

List queues to confirm:

1
aws --endpoint-url=http://localhost:4566 sqs list-queues

MiniStack with Testcontainers in Go

For automated tests you want the container to start and stop alongside the test. Testcontainers handles that.

1
go get github.com/testcontainers/testcontainers-go

The setup helper:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// testhelper_test.go
package handler_test

import (
	"context"
	"fmt"
	"testing"

	"github.com/aws/aws-sdk-go-v2/aws"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
	"github.com/aws/aws-sdk-go-v2/service/sqs"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

type testEnv struct {
	db       *dynamodb.Client
	sqsQueue *sqs.Client
	endpoint string
}

func setupMiniStack(t *testing.T) *testEnv {
	t.Helper()
	ctx := context.Background()

	req := testcontainers.ContainerRequest{
		Image:        "nahuelnucera/ministack:latest",
		ExposedPorts: []string{"4566/tcp"},
		WaitingFor:   wait.ForListeningPort("4566/tcp"),
	}

	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		t.Fatalf("ministack did not start: %v", err)
	}

	t.Cleanup(func() { container.Terminate(ctx) })

	host, _ := container.Host(ctx)
	port, _ := container.MappedPort(ctx, "4566")
	endpoint := fmt.Sprintf("http://%s:%s", host, port.Port())

	cfg := newConfig(ctx, endpoint)

	db := dynamodb.NewFromConfig(cfg)
	sqsClient := sqs.NewFromConfig(cfg)

	createOrdersTable(t, ctx, db)
	createOrdersQueue(t, ctx, sqsClient)

	return &testEnv{
		db:       db,
		sqsQueue: sqsClient,
		endpoint: endpoint,
	}
}

func newConfig(ctx context.Context, endpoint string) aws.Config {
	cfg, _ := awsconfig.LoadDefaultConfig(ctx,
		awsconfig.WithRegion("us-east-1"),
		awsconfig.WithCredentialsProvider(
			credentials.NewStaticCredentialsProvider("test", "test", ""),
		),
		awsconfig.WithEndpointResolverWithOptions(
			aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
				return aws.Endpoint{URL: endpoint}, nil
			}),
		),
	)
	return cfg
}

func createOrdersTable(t *testing.T, ctx context.Context, db *dynamodb.Client) {
	t.Helper()

	_, err := db.CreateTable(ctx, &dynamodb.CreateTableInput{
		TableName: aws.String("orders"),
		AttributeDefinitions: []types.AttributeDefinition{
			{AttributeName: aws.String("id"), AttributeType: types.ScalarAttributeTypeS},
		},
		KeySchema: []types.KeySchemaElement{
			{AttributeName: aws.String("id"), KeyType: types.KeyTypeHash},
		},
		BillingMode: types.BillingModePayPerRequest,
	})
	if err != nil {
		t.Fatalf("create table failed: %v", err)
	}
}

func createOrdersQueue(t *testing.T, ctx context.Context, sqsClient *sqs.Client) {
	t.Helper()

	_, err := sqsClient.CreateQueue(ctx, &sqs.CreateQueueInput{
		QueueName: aws.String("orders"),
	})
	if err != nil {
		t.Fatalf("create queue failed: %v", err)
	}
}

The Lambda function under test receives an SQS message and saves to DynamoDB:

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
46
47
48
49
50
51
52
53
54
55
// handler/handler.go
package handler

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

type Order struct {
	ID     string  `json:"id"`
	Amount float64 `json:"amount"`
	Status string  `json:"status"`
}

type Handler struct {
	db    *dynamodb.Client
	table string
}

func New(db *dynamodb.Client, table string) *Handler {
	return &Handler{db: db, table: table}
}

func (h *Handler) Handle(ctx context.Context, event events.SQSEvent) error {
	for _, record := range event.Records {
		var order Order
		if err := json.Unmarshal([]byte(record.Body), &order); err != nil {
			return fmt.Errorf("unmarshal failed: %w", err)
		}

		if order.ID == "" || order.Amount <= 0 {
			return fmt.Errorf("invalid order: %+v", order)
		}

		_, err := h.db.PutItem(ctx, &dynamodb.PutItemInput{
			TableName: aws.String(h.table),
			Item: map[string]types.AttributeValue{
				"id":     &types.AttributeValueMemberS{Value: order.ID},
				"amount": &types.AttributeValueMemberN{Value: fmt.Sprintf("%.2f", order.Amount)},
				"status": &types.AttributeValueMemberS{Value: order.Status},
			},
		})
		if err != nil {
			return fmt.Errorf("dynamo failed: %w", err)
		}
	}

	return nil
}

The tests using MiniStack:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// handler/handler_test.go
package handler_test

import (
	"context"
	"encoding/json"
	"testing"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
	"github.com/yourusername/order-processor/handler"
)

func TestHandle_ValidOrder(t *testing.T) {
	env := setupMiniStack(t)
	h := handler.New(env.db, "orders")

	order := handler.Order{ID: "order-1", Amount: 99.90, Status: "pending"}
	body, _ := json.Marshal(order)

	event := events.SQSEvent{
		Records: []events.SQSMessage,
	}

	if err := h.Handle(context.Background(), event); err != nil {
		t.Fatalf("handler returned error: %v", err)
	}

	result, err := env.db.GetItem(context.Background(), &dynamodb.GetItemInput{
		TableName: aws.String("orders"),
		Key: map[string]types.AttributeValue{
			"id": &types.AttributeValueMemberS{Value: "order-1"},
		},
	})
	if err != nil {
		t.Fatalf("getitem failed: %v", err)
	}

	if result.Item == nil {
		t.Fatal("order was not saved to dynamo")
	}
}

func TestHandle_InvalidOrder(t *testing.T) {
	env := setupMiniStack(t)
	h := handler.New(env.db, "orders")

	order := handler.Order{ID: "", Amount: -10}
	body, _ := json.Marshal(order)

	event := events.SQSEvent{
		Records: []events.SQSMessage,
	}

	if err := h.Handle(context.Background(), event); err == nil {
		t.Fatal("expected error for invalid order")
	}
}

func TestHandle_InvalidPayload(t *testing.T) {
	env := setupMiniStack(t)
	h := handler.New(env.db, "orders")

	event := events.SQSEvent{
		Records: []events.SQSMessage,
	}

	if err := h.Handle(context.Background(), event); err == nil {
		t.Fatal("expected error for invalid payload")
	}
}

func TestHandle_MultipleMessages(t *testing.T) {
	env := setupMiniStack(t)
	h := handler.New(env.db, "orders")

	orders := []handler.Order{
		{ID: "order-10", Amount: 50.00, Status: "pending"},
		{ID: "order-11", Amount: 150.00, Status: "pending"},
		{ID: "order-12", Amount: 300.00, Status: "pending"},
	}

	var records []events.SQSMessage
	for _, o := range orders {
		body, _ := json.Marshal(o)
		records = append(records, events.SQSMessage{Body: string(body)})
	}

	event := events.SQSEvent{Records: records}

	if err := h.Handle(context.Background(), event); err != nil {
		t.Fatalf("handler returned error: %v", err)
	}

	for _, o := range orders {
		result, err := env.db.GetItem(context.Background(), &dynamodb.GetItemInput{
			TableName: aws.String("orders"),
			Key: map[string]types.AttributeValue{
				"id": &types.AttributeValueMemberS{Value: o.ID},
			},
		})
		if err != nil || result.Item == nil {
			t.Fatalf("order %s was not saved", o.ID)
		}
	}
}

Run everything:

1
go test ./handler/... -v -timeout 120s

MiniStack starts, all four tests run, the container disappears. Around 10-15 seconds total, most of it being the image pull on the first run.


GitHub Actions with no secrets at all

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: stable

      - name: Run tests
        run: go test ./... -v -timeout 120s

No secrets, no AWS environment variables, no external account. Testcontainers uses the Docker daemon on the GitHub Actions runner and MiniStack starts inside it exactly like locally.


Separating unit tests from integration tests

Container-based tests take a few extra seconds. It makes sense to separate them so you can run only unit tests when you want fast feedback.

Add a build tag to integration tests:

1
2
3
//go:build integration

package handler_test

Run each type separately:

1
2
3
4
5
# unit tests only (instant)
go test ./...

# unit + integration (with container)
go test -tags integration ./...

In CI you decide where each runs. Pull requests can run only unit tests. Merges to main run everything.


State persistence between runs

By default MiniStack is stateless: each docker run starts fresh. For local development where you want to keep state between restarts, enable persistence:

1
2
3
4
5
docker run -p 4566:4566 \
  -e PERSIST_STATE=1 \
  -e STATE_DIR=/data \
  -v $(pwd)/ministack-data:/data \
  nahuelnucera/ministack

MiniStack saves state to /data on shutdown and reloads it on startup. Useful when you are developing and do not want to recreate resources every time.


Final comparison of the options

Each option has a different use case. The honest summary:

OptionCostLoginAWS servicesStartupBest for
Real AWS$0 (free tier)Yes (AWS account)AllN/ACI with low volume
MiniStackFreeNo35+~2sLocal dev and CI
DynamoDB Local + ElasticMQFreeNoDynamoDB + SQS~3sProjects needing only these two
LocalStack FreeFreeYes (required)Limited15-30sNon-commercial use
LocalStack Base$39/dev/monthYes80+15-30sTeams needing advanced services

For most Go projects with Lambda, SQS and DynamoDB, MiniStack covers everything needed. Free, no account, no token, starts in two seconds.


The real question

LocalStack was built on the narrative that testing on AWS is expensive. That narrative made more sense when the free tier was more restricted and prices were higher.

In 2026, with the always-free tier covering most testing scenarios and AWS having cut prices on services like DynamoDB, the math shifted. And with MiniStack available, you have a free local alternative that starts faster, uses less memory, and requires no signup.

This is not a criticism of LocalStack’s business decision. It is just worth running the numbers before renewing a license.


References