Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 8 - Terraform, tflocal, and GitHub Actions CI)

Provision the LocalStack stack from Parts 1-7 with Terraform, apply it with tflocal, and run integration tests in GitHub Actions CI. The capstone turns the series into a repeatable, tested workflow.

Share
Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 8 - Terraform, tflocal, and GitHub Actions CI)

By this point the series has built up a real local stack: buckets, tables, queues, topics, and secrets. This final part flips the workflow around. Instead of recreating those pieces by hand, you'll describe them once in Terraform, apply them with tflocal, and run integration tests in GitHub Actions on every push.

What you'll need: Part 0 setup, Terraform 1.6+, Python 3.12 with pytest and boto3, a GitHub account if you want CI to actually run, and 90 minutes.

Who this is for

  • You worked through the earlier LocalStack parts and now want one reproducible stack instead of a pile of one-off CLI commands.
  • You want proof that the stack works both locally and in a real GitHub Actions run before you point the same Terraform at AWS.

What I verified

  • A fresh prefixed LocalStack stack applied cleanly with tflocal, creating 13 resources across S3, DynamoDB, SNS, SQS, KMS, and Secrets Manager.
  • A follow-up tflocal plan returned No changes, so the stack was drift-free immediately after apply.
  • The six integration tests passed against the live stack, including SNS → SQS raw delivery, Secrets Manager retrieval, and the SQS redrive plus queue-policy wiring.
  • The GitHub Actions workflow was exercised for real against Ali-Shaikh/tflocal-test, where it applied the stack, ran the tests, and destroyed all 13 resources on cleanup.

What we're building

┌──────────────────────────────────────────────────────────┐
│  GitHub repo                                             │
│   ├── infra/                                             │
│   │    └── main.tf       (Terraform - declares stack)    │
│   ├── tests/                                             │
│   │    └── test_stack.py (pytest integration tests)      │
│   └── .github/workflows/                                 │
│        └── integration.yml (CI - runs on push/PR)        │
└──────────────────────────────────────────────────────────┘
                          │
                          ▼
            push or PR triggers workflow
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│  GitHub Actions runner                                   │
│   1. Start LocalStack                                    │
│   2. tflocal init && apply                               │
│   3. pytest tests/                                       │
│   4. tflocal destroy (cleanup)                           │
└──────────────────────────────────────────────────────────┘

Three files, one workflow. The Terraform stack covers everything we built in Parts 1, 2, 5, and 7 (plus what supports the Lambda articles in 3, 4, 6 - those Lambdas can be added later when you're ready to wire them in via Terraform or via your existing zip-and-deploy flow).

Why tflocal and not just terraform

tflocal is a thin wrapper around terraform that generates a temporary provider override file pointing the AWS provider at LocalStack. That means your main .tf file can stay AWS-shaped, while the wrapper handles the local endpoint wiring for you.

That portability is the entire point. You're not writing two stacks. You're writing one that you exercise locally and ship to AWS unchanged.

Step 1: Project layout

cd ~/projects/localstack-series
mkdir part8-terraform
cd part8-terraform
mkdir -p infra tests .github/workflows

Three directories. Terraform code lives in infra/, integration tests in tests/, and the CI workflow in .github/workflows/.

Step 2: The Terraform stack

Save as infra/main.tf:

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.70"
    }
  }
}

provider "aws" {
  region = "us-east-1"
  # tflocal writes a temporary provider override that points these resources
  # at LocalStack when you run `tflocal apply`. Plain `terraform apply`
  # against real AWS uses your normal AWS credentials.
}

# --- Storage ---------------------------------------------------------------

resource "aws_s3_bucket" "photos" {
  bucket = "photos"
}

resource "aws_s3_bucket" "thumbnails" {
  bucket = "thumbnails"
}

resource "aws_s3_bucket_cors_configuration" "photos" {
  bucket = aws_s3_bucket.photos.id
  cors_rule {
    allowed_headers = ["*"]
    allowed_methods = ["GET", "PUT"]
    allowed_origins = ["*"]
    expose_headers  = ["ETag"]
  }
}

# --- Database --------------------------------------------------------------

resource "aws_dynamodb_table" "shortlinks" {
  name         = "shortlinks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "code"

  attribute {
    name = "code"
    type = "S"
  }
}

# --- Messaging -------------------------------------------------------------

resource "aws_sns_topic" "user_events" {
  name = "user-events"
}

resource "aws_sqs_queue" "welcome_emails_dlq" {
  name = "welcome-emails-dlq"
}

resource "aws_sqs_queue" "welcome_emails" {
  name                       = "welcome-emails"
  visibility_timeout_seconds = 5
  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.welcome_emails_dlq.arn
    maxReceiveCount     = 3
  })
}

data "aws_iam_policy_document" "allow_sns_to_sqs" {
  statement {
    effect    = "Allow"
    actions   = ["sqs:SendMessage"]
    resources = [aws_sqs_queue.welcome_emails.arn]

    principals {
      type        = "Service"
      identifiers = ["sns.amazonaws.com"]
    }

    condition {
      test     = "ArnEquals"
      variable = "aws:SourceArn"
      values   = [aws_sns_topic.user_events.arn]
    }
  }
}

resource "aws_sqs_queue_policy" "welcome_emails" {
  queue_url = aws_sqs_queue.welcome_emails.url
  policy    = data.aws_iam_policy_document.allow_sns_to_sqs.json
}

resource "aws_sns_topic_subscription" "welcome_emails" {
  topic_arn            = aws_sns_topic.user_events.arn
  protocol             = "sqs"
  endpoint             = aws_sqs_queue.welcome_emails.arn
  raw_message_delivery = true
}

# --- Secrets ---------------------------------------------------------------

resource "aws_kms_key" "third_party" {
  description = "Encryption key for third-party API secrets"
}

resource "aws_kms_alias" "third_party" {
  name          = "alias/third-party-secrets"
  target_key_id = aws_kms_key.third_party.key_id
}

resource "aws_secretsmanager_secret" "api_key" {
  name        = "third-party/api-key"
  description = "Demo API key - managed by Terraform"
  kms_key_id  = aws_kms_key.third_party.arn
}

resource "aws_secretsmanager_secret_version" "api_key" {
  secret_id = aws_secretsmanager_secret.api_key.id
  secret_string = jsonencode({
    api_key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"
  })
}

# --- Outputs ---------------------------------------------------------------

output "photos_bucket"     { value = aws_s3_bucket.photos.id }
output "thumbnails_bucket" { value = aws_s3_bucket.thumbnails.id }
output "shortlinks_table"  { value = aws_dynamodb_table.shortlinks.name }
output "topic_arn"         { value = aws_sns_topic.user_events.arn }
output "queue_url"         { value = aws_sqs_queue.welcome_emails.url }
output "secret_arn"        { value = aws_secretsmanager_secret.api_key.arn }

A few details worth understanding:

  • No LocalStack endpoints in the main provider block. tflocal generates a temporary override file for the local run, so the main .tf stays AWS-shaped.
  • jsonencode for redrive policy. SQS expects the redrive policy as a JSON string, but writing it inline as a quoted-and-escaped JSON literal is awkward. jsonencode({...}) lets Terraform construct the string from a normal HCL map.
  • aws_sqs_queue_policy matters for real AWS. SNS needs explicit sqs:SendMessage permission on the queue. LocalStack can be more forgiving here, but the policy is part of the real architecture and should live in Terraform.
  • raw_message_delivery = true on the SNS-to-SQS subscription - same flag we used manually in Part 5. With it on, SQS receives just the message body; without it, the message is wrapped in an SNS envelope you'd have to unpack on every receive.
  • kms_key_id = aws_kms_key.third_party.arn ties the secret to a customer-managed key - same pattern as Part 7, just declared instead of executed.
  • aws_secretsmanager_secret_version stores the demo secret in Terraform state. That's acceptable for a tutorial stack. For real production secrets, keep the state backend encrypted and avoid committing long-lived live credentials into .tf files.

Step 3: Install tflocal and apply

pipx install 'terraform-local==0.26.0'

If you're already working inside a virtual environment, pip install terraform-local==0.26.0 is fine too. The direct pipx install is the cleaner default on a laptop because it avoids polluting your system Python.

Then:

cd infra
tflocal init
tflocal apply -auto-approve

For consistency with the tests and CI examples, set the standard local AWS env vars in your shell too:

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

Watch the output. Thirteen resources should land cleanly:

aws_kms_key.third_party: Creation complete after 0s [id=...]
aws_kms_alias.third_party: Creation complete after 0s [id=alias/third-party-secrets]
aws_sns_topic.user_events: Creation complete after 0s [id=...]
aws_dynamodb_table.shortlinks: Creation complete after 0s [id=shortlinks]
aws_s3_bucket.photos: Creation complete after 0s [id=photos]
aws_s3_bucket.thumbnails: Creation complete after 0s [id=thumbnails]
aws_s3_bucket_cors_configuration.photos: Creation complete after 0s [id=photos]
aws_secretsmanager_secret.api_key: Creation complete after 0s [id=...]
aws_secretsmanager_secret_version.api_key: Creation complete after 0s [id=...]
aws_sqs_queue.welcome_emails_dlq: Creation complete after 25s [id=...]
aws_sqs_queue.welcome_emails: Creation complete after 25s [id=...]
aws_sqs_queue_policy.welcome_emails: Creation complete after 25s [id=...]
aws_sns_topic_subscription.welcome_emails: Creation complete after 0s [id=...]

Apply complete! Resources: 13 added, 0 changed, 0 destroyed.

Outputs:
photos_bucket     = "photos"
thumbnails_bucket = "thumbnails"
shortlinks_table  = "shortlinks"
topic_arn         = "arn:aws:sns:us-east-1:000000000000:user-events"
queue_url         = "http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/welcome-emails"
secret_arn        = "arn:aws:secretsmanager:us-east-1:000000000000:secret:third-party/api-key-..."

The SQS queues take 25 seconds each - that's LocalStack waiting for the queue to be queryable post-create, mimicking the eventual consistency you'd see in real AWS.

Run tflocal plan afterwards and you should see "No changes. Your infrastructure matches the configuration." That's the test that you can rerun apply safely without drift - the foundational property of any IaC stack.

Step 4: Integration tests

A passing terraform apply proves resources were created. Integration tests prove the resources actually work. Save as tests/test_stack.py:

import json
import os
import uuid

import boto3

ENDPOINT = os.environ.get("AWS_ENDPOINT_URL", "http://localhost:4566")
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
PHOTOS_BUCKET = os.environ.get("PHOTOS_BUCKET", "photos")
THUMBNAILS_BUCKET = os.environ.get("THUMBNAILS_BUCKET", "thumbnails")
SHORTLINKS_TABLE = os.environ.get("SHORTLINKS_TABLE", "shortlinks")
TOPIC_ARN = os.environ.get("TOPIC_ARN", "arn:aws:sns:us-east-1:000000000000:user-events")
QUEUE_NAME = os.environ.get("QUEUE_NAME", "welcome-emails")
SECRET_NAME = os.environ.get("SECRET_NAME", "third-party/api-key")


def aws(service):
    return boto3.client(
        service,
        endpoint_url=ENDPOINT,
        region_name=REGION,
        aws_access_key_id="test",
        aws_secret_access_key="test",
    )


def aws_s3():
    return boto3.client(
        "s3",
        endpoint_url=ENDPOINT,
        region_name=REGION,
        aws_access_key_id="test",
        aws_secret_access_key="test",
        config=boto3.session.Config(s3={"addressing_style": "path"}),
    )


def queue_url():
    return aws("sqs").get_queue_url(QueueName=QUEUE_NAME)["QueueUrl"]


def test_s3_buckets_exist():
    names = [b["Name"] for b in aws_s3().list_buckets()["Buckets"]]
    assert PHOTOS_BUCKET in names
    assert THUMBNAILS_BUCKET in names


def test_s3_roundtrip_in_photos_bucket():
    s3 = aws_s3()
    key = f"test-{uuid.uuid4()}.txt"
    s3.put_object(Bucket=PHOTOS_BUCKET, Key=key, Body=b"hello from integration test")
    obj = s3.get_object(Bucket=PHOTOS_BUCKET, Key=key)
    assert obj["Body"].read() == b"hello from integration test"
    s3.delete_object(Bucket=PHOTOS_BUCKET, Key=key)


def test_dynamodb_shortlinks_table_works():
    ddb = aws("dynamodb")
    code = f"itest-{uuid.uuid4().hex[:8]}"
    ddb.put_item(TableName=SHORTLINKS_TABLE, Item={
        "code": {"S": code},
        "long_url": {"S": "https://example.com/from-tf"},
        "click_count": {"N": "0"},
    })
    out = ddb.get_item(TableName=SHORTLINKS_TABLE, Key={"code": {"S": code}})
    assert out["Item"]["long_url"]["S"] == "https://example.com/from-tf"
    ddb.delete_item(TableName=SHORTLINKS_TABLE, Key={"code": {"S": code}})


def test_sns_to_sqs_with_raw_delivery():
    sns, sqs = aws("sns"), aws("sqs")
    sns.publish(
        TopicArn=TOPIC_ARN,
        Message=json.dumps({"user_id": "itest", "email": "[email protected]"}),
    )

    out = sqs.receive_message(QueueUrl=queue_url(), MaxNumberOfMessages=5, WaitTimeSeconds=10)
    received = out.get("Messages", [])
    assert received, "no message received from the queue within 10s"
    body = json.loads(received[0]["Body"])
    assert body["email"] == "[email protected]"
    sqs.delete_message(QueueUrl=queue_url(), ReceiptHandle=received[0]["ReceiptHandle"])


def test_secrets_manager_secret_decrypts():
    out = aws("secretsmanager").get_secret_value(SecretId=SECRET_NAME)
    secret = json.loads(out["SecretString"])
    assert secret["api_key"].startswith("sk_")
    assert "AWSCURRENT" in out["VersionStages"]


def test_sqs_dlq_and_policy_are_wired():
    sqs = aws("sqs")
    attrs = sqs.get_queue_attributes(QueueUrl=queue_url(), AttributeNames=["RedrivePolicy", "Policy"])
    redrive = json.loads(attrs["Attributes"]["RedrivePolicy"])
    assert int(redrive["maxReceiveCount"]) == 3
    assert "welcome-emails-dlq" in redrive["deadLetterTargetArn"]

    policy = json.loads(attrs["Attributes"]["Policy"])
    stmt = policy["Statement"][0]
    assert stmt["Action"] == "sqs:SendMessage"
    assert "sns.amazonaws.com" in stmt["Principal"]["Service"]
    assert TOPIC_ARN == stmt["Condition"]["ArnEquals"]["aws:SourceArn"]

Six tests covering each major piece of the stack: buckets exist, S3 round-trip works, DynamoDB writes/reads cleanly, SNS-to-SQS fan-out delivers messages, Secrets Manager returns the JSON-encoded API key with the right version stage, and the SQS redrive policy plus SNS queue policy are correctly wired. The helper fetches the queue URL by name, so the same test file works locally and in CI without manually exporting QUEUE_URL.

The aws_s3() helper forces path-style addressing because it's the least surprising option when you're pointing SDK calls at LocalStack instead of real S3 endpoints.

Run them locally:

pip install pytest boto3
pytest -v tests/test_stack.py
tests/test_stack.py::test_s3_buckets_exist                PASSED [ 16%]
tests/test_stack.py::test_s3_roundtrip_in_photos_bucket   PASSED [ 33%]
tests/test_stack.py::test_dynamodb_shortlinks_table_works PASSED [ 50%]
tests/test_stack.py::test_sns_to_sqs_with_raw_delivery     PASSED [ 66%]
tests/test_stack.py::test_secrets_manager_secret_decrypts PASSED [ 83%]
tests/test_stack.py::test_sqs_dlq_and_policy_are_wired    PASSED [100%]

============================== 6 passed in 0.57s ==============================

Six tests, well under a second in my latest rerun. Real AWS calls would take longer for the same suite, which is one of the practical wins of LocalStack in CI.

Step 5: The GitHub Actions workflow

Save as .github/workflows/integration.yml:

name: Integration tests against LocalStack

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

jobs:
  integration:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    env:
      AWS_DEFAULT_REGION: us-east-1
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
      AWS_ENDPOINT_URL: http://localhost:4566

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: '3.12'

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: 1.15.1

      - name: Start LocalStack
        uses: LocalStack/[email protected]
        with:
          image-tag: '2026.5.3'
          install-awslocal: 'true'
        env:
          LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}

      - name: Install tflocal and pytest
        run: pip install terraform-local==0.26.0 pytest boto3

      - name: Apply Terraform
        working-directory: ./infra
        run: |
          tflocal init -input=false
          terraform validate
          tflocal apply -auto-approve -input=false

      - name: Run integration tests
        run: pytest -v tests/

      - name: Tear down (always runs, even on failure)
        if: always()
        working-directory: ./infra
        run: tflocal destroy -auto-approve -input=false

A few choices worth understanding:

  • Pinned action versions are deliberate. The workflow above was rechecked with actions/checkout@v6, actions/setup-python@v6, hashicorp/setup-terraform@v4, and LocalStack/[email protected], avoiding the older Node 20 runner deprecation warning and confirming the current LocalStack action release still works cleanly.
  • LocalStack/setup-localstack action handles the install + start + health check. It also takes care of mounting the Docker socket, which Lambda needs (Parts 3, 4, 6) - rolling your own service container is more lines and easier to get wrong.
  • LOCALSTACK_AUTH_TOKEN from GitHub Secrets. Add your token under Settings → Secrets and variables → Actions → New repository secret. Never commit it.
  • Same AWS_ENDPOINT_URL, key, secret env vars that you'd set locally. Tests don't know they're running in CI, and because they resolve the queue URL from QUEUE_NAME, they don't need a CI-only export step.
  • if: always() on the destroy step ensures cleanup runs even when tests fail - leaks state in CI is the kind of thing that bites two PRs later.
  • workflow_dispatch lets you trigger the run manually from the GitHub Actions UI, useful when you're iterating on the workflow itself.

Lint it before pushing:

brew install actionlint
actionlint .github/workflows/integration.yml
# (no output = no issues)

Step 6: Push it

git init
git add .gitignore infra/ tests/ .github/
git commit -m "chore: integration test stack via Terraform + LocalStack"
git remote add origin https://github.com/<you>/<your-repo>.git
git push -u origin main

Open the Actions tab in GitHub. On my verification repo, the real run against GitHub Actions finished in about 3.5 minutes end to end:

  • ~30s for runner setup
  • ~30s for LocalStack to start
  • ~1 minute for tflocal apply (LocalStack's lazy service init plus SQS settling)
  • ~5 seconds for the test suite
  • ~30s for tflocal destroy

Here is the latest passing run after the action-version bump:

GitHub Actions run for the Part 8 LocalStack workflow, showing the integration job succeeded and all six pytest integration tests passed

After that, the loop is fast. Every PR validates the stack end-to-end. Every push to main proves the Terraform applies cleanly. No AWS bill.

One thing that changed during verification: an earlier pass of this workflow still used older GitHub Action versions and tripped a Node 20 deprecation warning on hosted runners. Bumping those actions to their current major versions fixed that cleanly without changing the Terraform or test logic. I then re-ran the workflow after bumping LocalStack/setup-localstack to v0.3.2, and the full apply → test → destroy path still passed on GitHub-hosted runners.

Step 7: Same code, real AWS

The whole point of writing the stack in Terraform is that you can switch targets by changing the binary you call:

# Local - against LocalStack
tflocal apply

# Real AWS - same .tf, your AWS credentials in ~/.aws/credentials
terraform apply

That's it. The main provider config has no LocalStack-specific endpoints. tflocal handles the local override, while the integration tests use AWS_ENDPOINT_URL when it's set and standard AWS endpoints when it isn't - same boto3, same code paths.

Two things to watch for when you make the jump:

  1. Real costs. aws_kms_key is $1/month. aws_dynamodb_table is free at low throughput on PAY_PER_REQUEST. SQS, SNS, S3, Secrets Manager all have free tiers but bills add up. Run terraform destroy when you're not actively using the stack.
  2. IAM strictness. LocalStack only enforces IAM when you turn enforcement on. Real AWS always does. Add aws_iam_role and aws_iam_role_policy resources for any Lambda or service that needs to call other services, and test the IAM in a sandbox account before promoting to production.

If you do move this to AWS for real, add a remote Terraform backend early. S3 for state plus DynamoDB for locking is the normal shape, and it stops your first shared environment from turning into "who last touched terraform.tfstate?"

A note on managing existing resources

The first time you run this Terraform after manually building things in Parts 1-7, the resources already exist. Two ways to reconcile:

  • terraform import - pull each pre-existing resource into Terraform state without recreating it. The recommended approach when you've got data you want to keep.
  • Delete first, then apply - simplest, fine when there's no important state. Use the scoped delete pattern below: only the named resources from your stack, never a list-and-loop wipe.
# Scoped, safe - only the resources this Terraform stack manages
for b in photos thumbnails; do
  awslocal s3 rb s3://$b --force 2>/dev/null
done
awslocal dynamodb delete-table --table-name shortlinks 2>/dev/null
for q in welcome-emails welcome-emails-dlq; do
  url=$(awslocal sqs get-queue-url --queue-name $q --query QueueUrl --output text 2>/dev/null)
  [ -n "$url" ] && awslocal sqs delete-queue --queue-url $url 2>/dev/null
done
awslocal sns delete-topic --topic-arn arn:aws:sns:us-east-1:000000000000:user-events 2>/dev/null
awslocal secretsmanager delete-secret --secret-id third-party/api-key --force-delete-without-recovery 2>/dev/null
awslocal kms delete-alias --alias-name alias/third-party-secrets 2>/dev/null

Never run a for r in $(aws ... list-*); do delete $r; done loop against a shared LocalStack instance - you'll wipe everything, including resources from work unrelated to the stack you're cleaning up. The pattern above only touches the named resources this Terraform stack manages.

Common pitfalls

  • tflocal apply fails with connection refused on S3 buckets. LocalStack uses virtual-hosted-style S3 URLs by default. When you're testing against a remote LocalStack host instead of localhost:4566, add s3_use_path_style = true in a local-only override.
  • Apply complete! but resources don't show up in awslocal s3 ls. Two LocalStack instances running, on different ports. docker ps to find the duplicate.
  • GitHub Actions workflow hangs on "Start LocalStack". Auth token isn't set, or the LocalStack/setup-localstack Action version is pinned to something the registry has dropped. Bump the version pin and re-run.
  • Tests pass locally but fail in CI. The CI runner is a fresh container - it doesn't share your local LocalStack state. The CI workflow needs to apply the Terraform itself before testing. Check the order of steps.
  • terraform destroy leaves orphans. Resources created outside Terraform (manually via the AWS CLI, or by a previous failed run) aren't tracked in state. terraform import first or delete by name.

Cleanup

This walkthrough uses Terraform's default local state, which is why you see terraform.tfstate* in the cleanup step. There's no remote backend configured in the local-first version of this stack.

Locally:

# Tear down the whole stack
cd infra && tflocal destroy -auto-approve

# (Optional) wipe Terraform local state and lock
rm -rf .terraform .terraform.lock.hcl terraform.tfstate*

In CI: the workflow's if: always() destroy step handles it. Watch the Actions log if a run fails to confirm it actually ran.

Save this as a checkpoint

Part 8 doesn't need an init-hooks bootstrap script of its own - the Terraform is the bootstrap. If you've cloned the repo and want LocalStack to come up with everything pre-applied, the order is:

  1. docker compose up -d (Part 0's compose)
  2. cd infra && tflocal init && tflocal apply -auto-approve (this article)

That's the full project. If you're not using LocalStack persistence, a single tflocal apply brings the stack back whenever the container restarts.

If you'd rather keep the init-hooks pattern from earlier articles, you can drop a script at init/ready.d/08-part8-tflocal.sh that calls tflocal apply on container ready, but it's a circular setup (Terraform-managed state plus init-hook state on the same resources tends to cause drift warnings). Cleaner to pick one mechanism and stick with it.

What you've actually got now

  • A real AWS-shaped backend running locally - eight services, thirteen resources, one Terraform file.
  • An integration test suite that runs in under a second and exercises every major piece of the stack.
  • A GitHub Actions workflow that runs the same suite on every push and PR, no AWS account required.
  • The same .tf file applies cleanly against real AWS the moment you swap tflocal for terraform.

That's the full loop: iterate locally → test in CI → ship to production with no rewrites.

Where this series leaves you

Nine articles total, counting Part 0 through Part 8, one repo. You started with awslocal s3 mb and finished with a fully-tested, IaC-managed backend that ships on identical code to real AWS. Along the way:

  • Part 0 - set up LocalStack
  • Part 1 - S3 with presigned URLs
  • Part 2 - DynamoDB shortener data layer
  • Part 3 - Lambda + S3 events thumbnailer
  • Part 4 - API Gateway + Lambda + JWT auth
  • Part 5 - SQS + SNS background jobs with DLQ
  • Part 6 - EventBridge + Step Functions workflow
  • Part 7 - Secrets Manager + KMS
  • Part 8 - Terraform + GitHub Actions CI

Where to go from here: take this repo, point it at a real AWS sandbox account, watch the same code provision a real environment. Then start building something on top - the URL shortener fleshed out into a side project, the photo pipeline turned into a personal photo backup, whatever's been in your "I'd build this if AWS didn't cost money" notes file. The local loop is the same; the deploy target is your call.

If you've built something on this scaffolding, drop me a line.


Sources