A follow-up to How I run LocalStack without committing LOCALSTACK_AUTH_TOKEN. That post used Docker Compose and a generated .env. This one removes the file entirely.
LocalStack's unified image changed the shape of local integration tests. Since LocalStack for AWS moved to a unified image that requires LOCALSTACK_AUTH_TOKEN, getting realistic AWS behavior means you need a real token before the container even starts.
So the test fixture needs a secret before the test environment exists.
The usual fix is a generated .env:
- get the token into a secret store in the first place
- generate the file before tests
- gitignore it
- load it into Docker, Testcontainers, or Aspire
- keep local and CI aligned
It works.
But now there is a file whose only job is to carry a secret into your tests: something to generate, gitignore, keep in sync and never commit by accident. One more moving part that lives next to your real configuration instead of inside it.
When a tool expects a file (Docker Compose, shell scripts, legacy tooling), a .env is a good bridge.
When the consumer is code, the bridge is unnecessary.
Testcontainers and Aspire start LocalStack from inside your test process, and code can resolve the secret directly:
SSM -> memory -> container environment
Nothing to generate, nothing to gitignore, nothing to go stale.
This is not meant to replace your secrets manager. It just removes the generated .env hop when the consumer is already code.
Why not just call SSM directly?
You can. A direct ssm.getParameter(...) in the fixture is perfectly reasonable, and if your app is AWS-only and already loads Parameter Store through the native config provider (builder.Configuration.AddSystemsManager(...) in .NET), you probably do not need anything else in runtime. Reading secrets is a solved problem.
The part that stays implicit with direct calls is the contract: which logical keys this project needs, where each one lives, and what has to exist before it boots. That knowledge ends up scattered across C# code, SSM paths, GitHub Actions secrets and a stale README. Nobody has the full list in one place.
A committed map surfaces it. It is the list, versioned and reviewable in a PR:
LOCALSTACK_AUTH_TOKEN -> /demo/localstack/auth-token
DATABASE_URL -> /demo/db/url # new
Add a key and everyone sees the project grew a dependency. Because the code depends on the logical name and every consumer reads the same map, rotating a value, changing a path, or pointing at a different provider is an edit in one place rather than a hunt across call sites. The consumer still has to use the value it wants; it just never has to learn how to fetch it. In a monorepo, where each service commits its own envilder.json, that visibility compounds: you can see at a glance what every service depends on without reading its code.
That is the trade:
direct SDK call -> resolution logic per call site, contract stays implicit
shared contract -> one versioned map, every consumer resolves the same thing
One contract, many consumers: the .NET fixture, the Python one, the TypeScript setup and every pipeline all resolve the same committed map. It is the same instinct as infrastructure as code, applied to the secret contract: make the implicit explicit and put it under version control. That is a win on a single stack, before any multi-provider story enters the picture.
And if you already centralize this in one config layer, good: that layer is the contract. Envilder just makes it explicit and provider-agnostic instead of hand-rolled per stack.
One honest caveat while we are here: removing the .env is not a stronger runtime secret boundary. The token still ends up in the container environment because LocalStack needs it there. The improvement is operational: there is no intermediate .env file to generate, ignore, sync, clean up or accidentally commit.
The contract
One file, committed at the repo root. Paths, not values:
{
"$config": {
"provider": "aws",
"profile": "default"
},
"LOCALSTACK_AUTH_TOKEN": "/demo/localstack/auth-token"
}
The Envilder SDK resolves it at test time. Same pattern in every stack.
TypeScript
const environment = await Envilder.resolveFile('../envilder.json');
localstack = await new LocalstackContainer('localstack/localstack:stable')
.withEnvironment(Object.fromEntries(environment))
.start();
Full test: typescript-testcontainers/localstack.test.ts
Python
container = LocalStackContainer("localstack/localstack:stable")
container.env.update(Envilder.resolve_file(str(MAP_FILE)))
container.waiting_for(LogMessageWaitStrategy(re.compile(r"Ready\.")))
DockerContainer.start(container)
The explicit wait for the Ready. log line is there because the built-in wait does not match the consolidated 2026 LocalStack image.
Full test: python-testcontainers/test_localstack.py
.NET
var environment = await Env.ResolveFileAsync("envilder.json");
_container = new LocalStackBuilder("localstack/localstack:stable")
.WithEnvironment(new Dictionary<string, string>(environment))
.Build();
await _container.StartAsync();
Full fixture: dotnet-testcontainers/LocalStackFixture.cs
Same pattern, different orchestrator: .NET Aspire
In .NET, you might not be using Testcontainers directly. Aspire can also orchestrate containers, and it has its own LocalStack integration.
The AppHost resolves the token and injects it into the LocalStack resource:
builder.Configuration["LocalStack:UseLocalStack"] = "true";
var localstack = builder
.AddLocalStack("localstack", configureContainer: c => c.ContainerImageTag = "stable")
?? throw new InvalidOperationException("LocalStack is disabled.");
foreach (var (key, value) in await Env.ResolveFileAsync("envilder.json"))
{
localstack.WithEnvironment(key, value);
}
One gotcha: without that UseLocalStack config flag, AddLocalStack returns null. That is the package's fallback-to-real-AWS mechanism.
aspire run and you have a local AWS, token included, with nothing to export first.
And it is just as testable: Aspire.Hosting.Testing boots the same AppHost from an xUnit fixture, so the round-trip test works identically to the Testcontainers one.
Aspire is not .NET-only either. The same AppHost can orchestrate Python and JavaScript apps next to LocalStack, so a FastAPI service resolving its secrets with the Envilder Python SDK fits in the same graph.
Full AppHost and its test suite: dotnet-aspire/
That is the whole integration. The token travels from AWS SSM into the container's environment without being written to a generated file. When it rotates, I update it in SSM once per environment and every suite picks it up on the next run.
The recursion
This is exactly how Envilder's own test suite works. The SDK resolves the token that starts the LocalStack container used to test the SDK against an emulated SSM.
If Envilder cannot fetch its own secret, the suite fails before a single assertion runs. It is the most honest smoke test in the repo.
CI
The mechanism does not change. The secret does.
Locally, the profile in $config resolves my credentials from ~/.aws/credentials, and the map points to my developer LocalStack token.
In GitHub Actions, OIDC through aws-actions/configure-aws-credentials feeds the default credential chain, and the map can point to a CI Auth Token stored in SSM. LocalStack developer tokens are not meant for pipelines, so CI should use a CI-specific token.
Same contract, different credentials, different secret.
Try it
Minimal, runnable examples live in envilder-examples, one folder per stack and orchestrator:
typescript-testcontainerspython-testcontainersdotnet-testcontainersdotnet-aspire-
typescript-aspire: Aspire's TypeScript AppHost, whereapphost.tsresolves the token with the same@envilder/sdk
Each one resolves the token, boots LocalStack, and does a SecureString round-trip against emulated SSM.
Seeding the token is one command. That is the "get the token into a secret store" step from the top of this post, handled without any manual setup:
npx envilder --push --key=LOCALSTACK_AUTH_TOKEN \
--value=<your-token> \
--secret-path=/demo/localstack/auth-token
Where this came from
Envilder started as a fix for my own problem: I wanted the secret mapping in Git and the values out of it, resolved by whatever consumes them. This post is one concrete case where that helped, nothing more. If it turns out other people hit the same wall, there is obvious room to grow it (validating a map in CI, diffing a map against the store, and so on), but that is speculative and feedback-driven, not a promised roadmap. For now it does one thing, and this is that one thing.
Takeaway
secret values outside Git
secret mappings inside Git
resolution at runtime
no generated file when the consumer is code
one contract across every call site
.env files are the right bridge when a tool needs a file. Test fixtures are code. Let them resolve secrets through the same contract as everything else.
Links
- Example repo: https://github.com/macalbert/envilder-examples
- Envilder: https://envilder.com/
- Testcontainers: https://testcontainers.com/
- LocalStack Auth Token docs: https://docs.localstack.cloud/aws/getting-started/auth-token/
Thanks again to the LocalStack team for supporting Envilder through their Open Source program.

Top comments (0)