LocalStack as a service container in GitHub Actions, running tests in parallel with image caching, and integration with pytest, Jest, Mocha, testcontainers, and coverage reports.

In episode 17 we pulled real AWS resources into LocalStack for realistic testing. But all of that is wasted if the tests only run on your local machine. This episode automates everything: LocalStack in CI/CD, focused on GitHub Actions. The same patterns apply to GitLab CI, CircleCI, or Jenkins.
Why does this matter? Tests that only pass on your laptop are worthless to the team. With LocalStack as a service container in CI, every pull request gets a fresh, fast, free fake AWS environment — before code ever touches real AWS. Plus, following the principles from episode 15, we use PERSISTENCE=0 and unique credentials in CI.
The most common approach is making LocalStack a service container within a job. Service containers share a network with the job, so tests point their SDKs at localhost:4566 just like on a local machine:
name: ci
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
services:
localstack:
image: localstack/localstack:stable
ports:
- 4566:4566
env:
AWS_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm testThe matrix.node value is swapped per job by GitHub Actions, so three Node versions run in parallel — each with its own isolated LocalStack instance. That's parallelism: no shared state, no port conflicts, because each job has its own runner.
The more parallel jobs there are, the more often the LocalStack image must be pulled. Without caching, every runner downloads the image from scratch. A common technique is pre-pulling the image at the start of the job and caching the image as a tar from docker save via the actions/cache action:
- name: Cache LocalStack image
uses: actions/cache@v4
with:
path: /tmp/localstack-image.tar
key: localstack-${{ runner.os }}
- name: Load atau pull image
run: |
if [ -f /tmp/localstack-image.tar ]; then
docker load -i /tmp/localstack-image.tar
else
docker pull localstack/localstack:stable
docker save localstack/localstack:stable -o /tmp/localstack-image.tar
fiThe runner.os expression inside the key keeps a separate cache per runner OS. Combining parallelism with caching speeds up the pipeline significantly, especially for monorepos with many services.
If a job needs extra env vars (e.g. DEBUG=1 or a specific SERVICES), the service container can be replaced with a step that runs the container manually:
- name: Start LocalStack
run: |
docker run -d --name localstack \
-e DEBUG=1 -e PERSISTENCE=0 \
-p 4566:4566 localstack/localstack:stable
until curl -s http://localhost:4566/_localstack/health | grep -q running; do
sleep 1
doneThe until loop ensures the job doesn't start tests before LocalStack is truly ready — a handy simple readiness gate.
A boto3 fixture pointing at the local endpoint makes tests self-contained:
import boto3
import pytest
ENDPOINT_URL = "http://localhost:4566"
@pytest.fixture
def s3_client():
return boto3.client(
"s3",
endpoint_url=ENDPOINT_URL,
aws_access_key_id="test",
aws_secret_access_key="test",
)
def test_bucket_lifecycle(s3_client):
s3_client.create_bucket(Bucket="artifacts")
assert s3_client.list_buckets()["Buckets"][0]["Name"] == "artifacts"In Jest, just set the environment variables in a setup file:
process.env.AWS_ENDPOINT_URL = "http://localhost:4566"
process.env.AWS_ACCESS_KEY_ID = "test"
process.env.AWS_SECRET_ACCESS_KEY = "test"
process.env.AWS_DEFAULT_REGION = "us-east-1"Mocha uses a similar setup file via the --require flag:
npx mocha --require ./test/setup.js ./test/aws.spec.jsFor tests that want a truly fresh instance per test suite, use the @testcontainers/localstack module. The container is created, used, then stopped automatically:
const { LocalStackContainer } = require("@testcontainers/localstack")
const { S3Client, ListBucketsCommand } = require("@aws-sdk/client-s3")
test("s3 di dalam container lokal", async () => {
const container = await new LocalStackContainer().start()
const endpoint = container.getConnectionUri()
const client = new S3Client({
endpoint, region: "us-east-1",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
})
await client.send(new ListBucketsCommand({}))
await container.stop()
})In Python there's the counterpart: the testcontainers package with the localstack module. This pattern is ideal for developers who don't want to run LocalStack permanently.
Tests without coverage reports are hard to justify. Enable coverage in each framework, then upload it as an artifact:
- run: npx jest --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.node }}
path: coverage/For Python, pytest --cov=. --cov-report=term-missing gives a per-line report. The artifact can be downloaded from the run page for reviewers, or published to a service like Codecov.
Summary of CI/CD integration with LocalStack:
/_localstack/health readiness check before tests start.The pipeline now runs automatically — but what if a test suddenly fails for no clear reason? In the next episode 19 we discuss Performance & Troubleshooting: reading logs with DEBUG=1, leveraging the health endpoint, monitoring Docker resources, and solving the most common problems like port conflicts and Lambda not being invoked. See you there!