Cloud infrastructure development has a friction problem. The standard workflow, like writing Terraform, push to a shared dev environment, wait for a plan, apply, observe, fix, and repeat is slow by nature. Every cycle crosses the network, touches real AWS resources, and generates costs.
Shared development environments compound this: two engineers applying conflicting Terraform changes to the same account produce failures that are hard to attribute and expensive to roll back.
LocalStack is a local cloud emulator that lets you build and test cloud applications entirely on your machine, spinning up fully functional local environments that mirror real cloud behavior without provisioning real cloud infrastructure.
Notice something important here: the word emulator. It's not mocking a service or its implementation; LocalStack exposes real AWS API endpoints locally. Your Terraform code, AWS CLI commands, and SDKs communicate with it using the same calls they would make against real AWS. The only difference is the endpoint URL.
https://docs.localstack.cloud/aws/getting-started/
This guide walks through setting up a fully local AWS development environment using Terraform and LocalStack. We'll configure LocalStack in Docker, wire up Terraform to deploy against it using tflocal, and build three Free-tier services into an event-driven pattern: an S3 bucket, an SQS queue, and a Lambda function.
Along the way, we'll look at where LocalStack's behavior matches real AWS and where it doesn't — so you know what you can trust locally and what still needs a real environment to validate.
The assumption is that you already know Terraform and have basic AWS familiarity. This guide is about the local testing workflow, not Terraform or AWS fundamentals.
How AWS API Calls Are Intercepted
LocalStack runs as a single container and exposes a unified endpoint (default: port 4566) that handles requests for all emulated AWS services. To connect, you expose port 4566 from your LocalStack instance and point your tools at localhost or a domain name that resolves to it.
There are two ways your tools can reach it
Manual endpoint override
You explicitly point your AWS CLI, SDK, or Terraform provider at http://localhost.localstack.cloud:4566. This hostname is the recommended endpoint, especially for S3, since it enables host-based bucket addressing. This is the approach used with Terraform via the tflocal wrapper, covered in the next section.
Transparent endpoint injection
LocalStack also provides transparent endpoint injection, which enables seamless connectivity without modifying application code that targets AWS. Its DNS server resolves AWS domains (including subdomains like *.amazonaws.com) to the LocalStack container, so your application hits LocalStack APIs instead of real AWS without any code changes.
For Terraform workflows, manual endpoint override via tflocal is the standard and more explicit approach. Transparent endpoint injection is better suited for cases where application code must remain untouched.
Tiers
LocalStack offers several tiers: Free, Base, Ultimate, Enterprise, and Student (verified through a GitHub Education account). As you'd expect, higher tiers unlock more advanced features, tooling, and support.
https://docs.localstack.cloud/aws/licensing/#introduction
An important clarification: the capabilities and limitations of each emulated service don't depend on your plan — they depend on LocalStack's own per-service implementation. You can find details under the "Current Limitations" section for each service you plan to use, which helps you avoid the classic "works locally, fails in AWS" problem.
Example: https://docs.localstack.cloud/aws/services/elb/#current-limitations
Prerequisites
We need a few tools installed before starting
You have two options here. One is to use the LocalStack CLI, which you can find installation instructions for here: https://docs.localstack.cloud/aws/getting-started/installation/
The other option is to run the LocalStack container yourself using a docker-compose file. For reproducibility and compatibility reasons, we'll go with this approach in this guide.
Official docker-compose configuration
Token configuration
As mentioned earlier, you now need to set the auth token when working with LocalStack:
Using the docker-compose file we shared above along with your token, you should see something similar to this

This configuration step is important because it's where a lot of people hit their first issue when setting up Terraform + LocalStack. By default, Terraform uses real AWS endpoints. We need to redirect those requests to localhost:4566, and there are two ways to do it.
tflocal
tflocal is a small wrapper around the Terraform binary. When you run tflocal apply instead of terraform apply, it injects a temporary override file that redirects every AWS service endpoint to LocalStack before Terraform runs.
With tflocal, your entire Terraform configuration stays the same — you don't need to define localhost or dummy credentials anywhere.
Manual configuration
It may also be the case that for whatever reason you don't want to use tflocal. In that scenario, you can define the endpoints manually, which would look something like this:
A few things to point out here that aren't obvious. The skip_* flags exist because LocalStack doesn't enforce real AWS credential validation or instance metadata checks; without them, Terraform throws authentication errors before it even sends a request.
Also, the S3 endpoint uses a different hostname than the rest of the services. This is because S3 virtual-hosted-style bucket addressing requires a hostname that includes the bucket name as a subdomain (e.g., mybucket.s3.localhost.localstack.cloud), which localhost:4566 alone cannot support.
If everything went well, you should see something similar to this:

Once you confirm an empty result from s3 ls, the configuration is up and running. It means LocalStack accepted the request and found no buckets yet.
If you run into problems, make sure the container is running and port 4566 is exposed by checking with docker ps.
Project structure
Before writing any files, let's establish a working structure for the project. The final layout should look something like this:
Root Level Variables and Provider
S3 — Bucket Configuration and What Behaves Differently Locally
As mentioned at the beginning, this guide won't go deep into explaining Terraform resources since that's outside the scope. But given Terraform's straightforward syntax, following the workflow should be easy enough.
Finally, the outputs and adding the S3 module to the root main.tf:
With these files in place, we're ready to apply the changes:
Verify the bucket was created
Upload a test object and retrieve it
What behaves differently from real AWS
Once we've confirmed everything works as expected, we can talk about what to expect in terms of differences between this local setup and a real AWS environment.
Virtual-hosted-style addressing
When an AWS SDK builds a request to S3, it constructs the URL by placing the bucket name as a subdomain: my-bucket.s3.amazonaws.com. LocalStack needs to receive the request in that same format to know which bucket it's targeting (it reads the bucket name from the Host header).
The interesting part is how each tool constructs the URL
When using tflocal, the wrapper automatically injects s3.localhost.localstack.cloud:4566 as the endpoint.
When using the AWS CLI with --endpoint-url=http://localhost:4566, the client detects that it's not a real AWS S3 endpoint and automatically falls back to path-style: localhost:4566/uploads-local/object, decomposing the s3://uploads-local/test.json used in the request.
s3_use_path_style = true exists in case you need to force path-style from Terraform as well, but with tflocal it's not necessary.
About Bucket Names
In real AWS, bucket names are globally unique. LocalStack doesn't have that restriction. Adding a suffix like local is simply a naming convention so you don't confuse local buckets with production ones when inspecting resources.
Once the Lambda is ready, we need to compress the necessary files into a .zip. Lambda will decompress and execute these files in its runtime environment.
Once we confirm the .zip was successfully created, it's time to continue with the .tf files.
https://docs.localstack.cloud/aws/services/lambda/
Now, check if everything is working as expected:
LocalStack creates a separate container for the Lambda, naming it by concatenating several identifiers. If you want to see the logs from the function, you need to inspect that container's logs:
You probably noticed we built an IAM role in the Lambda module's main file. This matters because AWS Lambda validates via regex that the role follows a valid ARN format, even though LocalStack doesn't check whether the role actually exists or has any real permissions.
Checking Results
After a few seconds, you should see a new container with a different ID for the Lambda service. If you check the logs again, you should see something like this:
https://docs.localstack.cloud/aws/services/sqs/
LocalStack is an emulator, not a perfect replica. Most gaps fall into a few predictable categories.
IAM is mostly a no-op by default. Policies, roles, and permission boundaries are accepted and stored, but not enforced unless you explicitly enable it.
Your Lambda can access your S3 bucket locally even if the role has no permissions. This means IAM misconfigurations won't surface until you hit the real AWS. This is important because IAM is a key principle when restricting content to certain users/roles.
Error behavior isn't always identical. Real AWS returns specific error codes with specific conditions. LocalStack sometimes returns a generic error, a different error, or no error at all for the same situation.
Eventual consistency is largely absent. AWS services like S3 and DynamoDB have consistency models that occasionally matter in production. LocalStack runs everything in-process, so operations that would be eventually consistent in AWS are effectively immediate locally. Race conditions won't reproduce.
The general rule
Use LocalStack to validate that your infrastructure wires up correctly and your happy path works. Use real AWS (a dev or staging account) to validate permissions, error handling, and anything latency or consistency-sensitive.
What We Built
Starting from an empty directory, this guide walked through setting up a complete local AWS development environment with LocalStack running in Docker, Terraform configured to deploy against it via tflocal, and three Free-tier services wired into a real event-driven pattern: an S3 bucket for storage, an SQS queue as the message bus, and a Lambda function processing those messages.
More importantly, we looked at what LocalStack actually does and doesn't enforce. Where it cuts corners on purpose (credential validation, global bucket uniqueness, queue deletion delays) and where its behavior diverges in ways that can silently mask production bugs.
When This Workflow Is the Right Fit
This approach pays off most when your infrastructure involves multiple interacting services — the scenario where a change to one resource can break another in ways that only surface at integration time. Testing that SQS correctly triggers your Lambda, that your Lambda reads from S3, that your Terraform modules compose without conflicts. None of those tasks requires a real AWS account, and all of it benefits from tight local feedback loops.
When It Isn't
Local testing is not a substitute for staging. IAM policy enforcement, VPC networking, real service limits, and the performance characteristics of managed services all require a real AWS environment to validate meaningfully.
LocalStack's Free tier skips credential validation entirely, which means IAM misconfigurations are invisible until you deploy to a real account. The goal is to catch structural problems locally and let staging catch the subtler ones. If your infrastructure is a single service with no integrations, the overhead of this setup probably isn't worth it. A direct deployment to a dev environment is often faster.
Next Steps
The stack here is intentionally minimal. A few directions worth exploring:
More Free-tier services. DynamoDB, SNS, EventBridge, Step Functions, and CloudWatch Logs are all available. The pattern is the same: write a Terraform module, init and apply with tflocal, verify with awslocal. A natural next step with this exact stack would be having the Lambda write processed messages to a DynamoDB table. Make sure to try your best on this new learning path!