Skip to content

timpugh/lambda-powertools-reference

Repository files navigation

Lambda Powertools Reference

CI Python 3.12 Docs License

Docs: https://timpugh.github.io/lambda-powertools-reference/

This project contains source code and supporting files for a serverless application that you can deploy with the AWS CDK. It includes the following files and folders.

  • app.py - CDK entry point; instantiates the WAF, backend, and frontend stacks and calls app.synth()
  • lambda/ - Code for the application's Lambda function
  • hello_world/hello_world_stack.py - The backend CDK stack (Lambda, API Gateway, DynamoDB, SSM, AppConfig)
  • hello_world/hello_world_waf_stack.py - The WAF stack (CloudFront-scoped WebACL, always in us-east-1)
  • hello_world/hello_world_frontend_stack.py - The frontend stack (S3 + CloudFront)
  • hello_world/nag_utils.py - Shared cdk-nag suppression list for CDK-managed singleton Lambdas
  • frontend/ - Static assets (index.html) deployed to the frontend S3 bucket
  • events/event.json - A sample API Gateway proxy event for local SAM invocation
  • tests/ - Unit and integration tests
  • tests/conftest.py - Shared test fixtures (API Gateway event, Lambda context, mocks)
  • docs/ - Sphinx documentation source files
  • pyproject.toml - Consolidated tool configuration (ruff, mypy, pylint, pytest, coverage)
  • .pre-commit-config.yaml - Pre-commit hook definitions (runs on every git commit)
  • .bandit - Bandit security scanner configuration (excluded directories)
  • .vscode/ - VS Code workspace settings and recommended extensions (ruff, mypy, pylint, pytest)
  • .github/workflows/ - GitHub Actions workflows (ci.yml, docs.yml, dependency-audit.yml, dependabot-auto-merge.yml)
  • .github/dependabot.yml - Dependabot configuration (weekly checks for GitHub Actions and all three Python requirements tiers)
  • Makefile - Common development commands (make help to list all targets)
  • LICENSE - Apache 2.0 license
  • TODO.md - Outstanding work and deferred items

The application uses several AWS resources, including Lambda functions, an API Gateway API, a DynamoDB table, SSM parameters, AppConfig, an S3-backed CloudFront distribution, and a WAF WebACL. These resources are split across three stack files in hello_world/ (hello_world_stack.py for the backend, hello_world_waf_stack.py for WAF, and hello_world_frontend_stack.py for S3/CloudFront). The Lambda function uses AWS Lambda Powertools extensively — see the Lambda Powertools features section below for details. Note that Powertools Tracer currently depends on the aws-xray-sdk, which is approaching deprecation. There is an open RFC to replace it with OpenTelemetry as the tracing provider. You can update the stack to add AWS resources through the same deployment process that updates your application code.

Lambda Powertools features

The Lambda function in lambda/app.py uses the following Powertools utilities:

Logger

Structured JSON logging with @logger.inject_lambda_context. Automatically includes Lambda context fields (function name, request ID, cold start) in every log entry. Configured via POWERTOOLS_SERVICE_NAME and POWERTOOLS_LOG_LEVEL environment variables.

Tracer

X-Ray tracing with @tracer.capture_lambda_handler on the entry point and @tracer.capture_method on route handlers. Creates subsegments for each traced method.

Metrics

CloudWatch Embedded Metric Format (EMF) via @metrics.log_metrics(capture_cold_start_metric=True). The /hello route emits a HelloRequests count metric. Metrics are published under the HelloWorld namespace (set via POWERTOOLS_METRICS_NAMESPACE).

Event Handler

APIGatewayRestResolver provides Flask-like routing with @app.get("/hello"). It parses the API Gateway event and routes to the correct handler based on HTTP method and path.

Idempotency

The @idempotent decorator uses a DynamoDB table to prevent duplicate processing of the same request. It keys on requestContext.requestId and records expire after 1 hour. The CDK stack provisions the DynamoDB table with PAY_PER_REQUEST billing and a TTL attribute.

Parameters

get_parameter() fetches the greeting message from SSM Parameter Store. The parameter path is set via the GREETING_PARAM_NAME environment variable. Values are cached automatically by Powertools to reduce API calls.

Feature Flags

FeatureFlags reads from AWS AppConfig to toggle behavior at runtime. The enhanced_greeting flag controls whether the response includes extra text. The CDK stack provisions the AppConfig application, environment, configuration profile, and an initial hosted configuration version.

Validation

validate(event=response, schema=RESPONSE_SCHEMA) checks the route handler's return value against a JSON Schema before the resolver wraps it into the API Gateway proxy response. This catches malformed responses at the source rather than after serialization.

Event Source Data Classes

APIGatewayProxyEvent provides typed access to the incoming API Gateway event. Instead of raw dict access like event["requestContext"]["identity"]["sourceIp"], you get event.request_context.identity.source_ip with IDE autocomplete and type safety. Powertools includes data classes for many event sources:

  • APIGatewayProxyEvent / APIGatewayProxyEventV2 — REST and HTTP API events
  • S3Event — S3 bucket notifications
  • SQSEvent — SQS messages
  • DynamoDBStreamEvent — DynamoDB stream records
  • EventBridgeEvent — EventBridge events
  • SNSEvent, KinesisStreamEvent, CloudWatchLogsEvent, and more

These are available from aws_lambda_powertools.utilities.data_classes and require no extra dependencies.

AWS resources provisioned

Resources are split across three stacks. All resources in all stacks have RemovalPolicy.DESTROY so cdk destroy leaves nothing behind.

HelloWorldWaf-{region} (always in us-east-1):

Resource Purpose
WAF WebACL CloudFront-scoped WebACL with 4 managed rules + rate limiting
KMS Key Encrypts the WAF log group
CloudWatch Log Group (aws-waf-logs-*) Receives WAF access logs

HelloWorld-{region} (backend, target region):

Resource Purpose
KMS Key Encrypts all log groups and DynamoDB
Lambda Function Runs the hello-world handler (256 MB, X-Ray tracing, JSON logging)
CloudWatch Log Group Lambda log group with 1-week retention, KMS-encrypted
API Gateway REST API Exposes GET /hello with X-Ray tracing, 0.5 GB encrypted cache
CloudWatch Log Group (access) API Gateway access logs, KMS-encrypted
CloudWatch Log Group (execution) API Gateway execution logs, KMS-encrypted
DynamoDB Table Idempotency records (TTL, PAY_PER_REQUEST, PITR, KMS-encrypted)
SSM Parameter Greeting message (/{stack}/greeting)
AppConfig Application Feature flag configuration
AppConfig Environment {stack}-env environment for feature flags
AppConfig Configuration Profile {stack}-features profile with AWS.AppConfig.FeatureFlags type
Resource Group + Application Insights CloudWatch Application Insights monitoring
CloudWatch Dashboard Lambda, API GW, DynamoDB metrics via cdk-monitoring-constructs
Custom Resource (AppInsightsDashboardCleanup) Deletes the Application Insights auto-created dashboard on destroy

HelloWorldFrontend-{region} (frontend, target region):

Resource Purpose
KMS Key Encrypts the frontend S3 bucket and auto-delete Lambda log group
S3 Bucket (frontend) Private static assets, KMS-encrypted, server access logging enabled
S3 Bucket (access logs) Receives S3 server access logs (SSE-S3 — log delivery requires it)
CloudFront Distribution HTTPS-only, TLS 1.2+, WAF-protected, SECURITY_HEADERS policy
CloudWatch Log Group (auto-delete) Auto-delete Lambda log group, KMS-encrypted

Quick start

Just want to explore the code and run tests without deploying anything to AWS?

git clone https://github.com/timpugh/lambda-powertools-reference.git
cd lambda-powertools-reference
python3 -m venv .venv && source .venv/bin/activate
make install
make test

No AWS credentials or deployed stack required — unit tests mock all external dependencies.

If you open the project in VS Code, the .vscode/ directory pre-configures ruff (format on save), mypy, pylint, and pytest against pyproject.toml. The first time you open it, VS Code will prompt you to install the recommended extensions listed in .vscode/extensions.json.

Makefile

Common commands are available via make. Run make help to see all targets:

make install        # set up venv with all dependencies and pre-commit hooks
make test           # run unit tests with coverage
make test-integration  # run integration tests (requires deployed stack)
make lint           # run all pre-commit hooks (ruff, mypy, pylint, bandit, xenon, pip-audit)
make format         # format code with ruff
make typecheck      # run mypy type checking
make security       # run bandit + pip-audit
make docs           # build Sphinx HTML docs
make docs-open      # build and open docs in browser
make compile        # regenerate all lock files from .in sources
make upgrade        # upgrade all dependencies (respects COOLDOWN_DAYS, default 7)
make clean          # remove build artifacts, caches, and coverage files

Prerequisites

To use the CDK, you need the following tools.

Deploy the application

This project uses Finch as the container runtime for bundling Lambda dependencies during synthesis. Set the CDK_DOCKER environment variable before running CDK commands (see the CDK GitHub issue where this was added):

export CDK_DOCKER=finch

To set up and deploy your application for the first time, run the following in your shell:

# Create and activate a virtual environment
python3 -m venv .venv
source .venv/bin/activate

# Install pip-tools first (needed for pip-sync)
pip install pip-tools

# Install dev dependencies (CDK, linting, type checking) via pip-sync
pip-sync requirements.txt

# Add test and Lambda dependencies on top (additive — does not remove dev deps)
pip install -r tests/requirements.txt -r lambda/requirements.txt

# Shortcut for the three steps above (after activating the venv):
# make install

# Make sure Finch is running
finch vm start

# Set Finch as the container runtime for CDK
export CDK_DOCKER=finch

# Bootstrap CDK in us-east-1 (always required — WAF stack always deploys here)
cdk bootstrap aws://YOUR_ACCOUNT_ID/us-east-1

# Deploy all stacks to us-east-1 (default)
cdk deploy --all

The cdk synth and cdk deploy commands use Finch to build a container that installs the Lambda dependencies from lambda/requirements.txt into the deployment package. The first run will be slower as it pulls the SAM build image.

After deployment, CloudFormation outputs useful values directly in the terminal. Each stack exposes the following:

HelloWorldWaf-{region}:

  • WebAclArn — WAF WebACL ARN (also used internally by the frontend stack)
  • WebAclId — WAF WebACL logical ID
  • WafLogGroupName — CloudWatch log group name for WAF access logs

HelloWorld-{region}:

  • HelloWorldApiOutput — API Gateway endpoint URL (https://.../Prod/hello)
  • HelloWorldFunctionOutput — Lambda function ARN
  • HelloWorldFunctionIamRoleOutput — Lambda IAM role ARN
  • IdempotencyTableName — DynamoDB table name
  • GreetingParameterName — SSM parameter path
  • AppConfigAppName — AppConfig application name
  • CloudWatchDashboardUrl — Direct link to the CloudWatch monitoring dashboard

HelloWorldFrontend-{region}:

  • CloudFrontDomainNamehttps:// URL to open in a browser
  • CloudFrontDistributionId — Distribution ID for manual cache invalidations
  • FrontendBucketName — S3 bucket name for direct asset inspection

Deploying to a different region

Each target region must be bootstrapped before its first deploy. Bootstrap is a one-time step per region per account.

# Bootstrap the target region (in addition to us-east-1 which is always needed)
cdk bootstrap aws://YOUR_ACCOUNT_ID/ap-southeast-1

# Deploy all stacks — WAF stays in us-east-1, backend and frontend go to ap-southeast-1
cdk deploy --all -c region=ap-southeast-1

Destroying a deployment

# Destroy the default us-east-1 deployment
cdk destroy --all

# Destroy a specific regional deployment (does not affect other regions)
cdk destroy --all -c region=ap-southeast-1

Useful CDK commands

  • cdk ls list all stacks in the app
  • cdk synth emit the synthesized CloudFormation template
  • cdk deploy --all deploy all stacks to us-east-1 (default)
  • cdk deploy --all -c region=X deploy all stacks to region X
  • cdk diff compare deployed stack with current state
  • cdk destroy --all destroy all stacks in the default region

Use the CDK to build and test locally

Synthesize your application to verify the CloudFormation template (requires Finch running):

export CDK_DOCKER=finch
cdk synth

You can invoke the Lambda function locally using the SAM CLI with the synthesized template:

sam local invoke HelloWorldFunction -t cdk.out/HelloWorld.template.json --event events/event.json

events/event.json is a sample API Gateway REST proxy event that simulates a GET /hello request. It includes realistic headers, a requestContext with a unique requestId (used by idempotency), and placeholder CloudFront fields. Use it as a starting point for local invocation — edit the httpMethod, path, or body fields to test different scenarios.

You can also emulate the API locally:

sam local start-api -t cdk.out/HelloWorld.template.json
curl http://localhost:3000/hello

Note: Local invocation requires Finch to be running:

finch vm start

Fetch, tail, and filter Lambda function logs

You can use the SAM CLI to fetch logs from your deployed Lambda function:

sam logs -n HelloWorldFunction --stack-name "HelloWorld" --tail

This works for any AWS Lambda function, not just ones deployed with SAM. See the SAM CLI logging documentation for more on filtering and searching logs.

Add a resource to your application

To add AWS resources, define new constructs in the appropriate stack file under hello_world/: backend resources (Lambda, API Gateway, DynamoDB, SSM, AppConfig) belong in hello_world_stack.py, frontend resources (S3, CloudFront) belong in hello_world_frontend_stack.py, and WAF rules belong in hello_world_waf_stack.py. The CDK provides high-level constructs for most AWS services. Browse available constructs in the AWS CDK API Reference. For resources without a dedicated CDK construct, you can use CloudFormation resource types directly via CfnResource.

Tests

Tests are defined in the tests folder in this project. Make sure dependencies are installed first (see Deploy the application).

Unit test architecture

Unit tests mock all external AWS dependencies so they run locally without credentials or a deployed stack. The key patterns used:

Shared fixtures via conftest.py — Reusable fixtures live in tests/conftest.py, including the API Gateway event, Lambda context mock, and the Lambda app module reference. The autouse mock that patches SSM Parameters and Feature Flags lives in tests/unit/conftest.py so it only applies to unit tests. Test files stay clean and focused on assertions.

Environment variables — All test env vars are centralized in pyproject.toml via pytest-env. This includes Powertools config, mock resource names, and the idempotency disable flag. No os.environ calls needed in test files.

Idempotency disabled via env varPOWERTOOLS_IDEMPOTENCY_DISABLED=true is set in pyproject.toml to tell Powertools to skip DynamoDB calls during tests. This is the recommended approach from Powertools docs. In production, this env var is not set, so idempotency is fully active.

Mocking external calls with pytest-mock — SSM Parameters and Feature Flags are mocked in tests/unit/conftest.py using mocker.patch.object():

mocker.patch.object(lambda_app, "get_parameter", return_value="hello world")
mocker.patch.object(lambda_app.feature_flags, "evaluate", return_value=False)

Lambda context via pytest-mock — A MagicMock provides the Lambda context object with realistic attributes (function name, ARN, request ID).

Import path isolation — The lambda/ directory is added to sys.path in tests/conftest.py before the root directory to ensure import app resolves to the Lambda handler (lambda/app.py) and not the CDK entry point (app.py).

Running unit tests

python -m pytest tests/unit -v
# Shortcut: make test

Integration tests

Two suites of integration tests verify the live deployment:

API Gateway (tests/integration/test_api_gateway.py) — calls the live API Gateway endpoint and verifies the response body, content type headers, and response time (under 10 seconds, to account for Lambda cold starts with SSM and AppConfig initialization). The backend stack name is read from AWS_BACKEND_STACK_NAME in pyproject.toml (defaults to HelloWorld-us-east-1). Override for a different region:

AWS_BACKEND_STACK_NAME=HelloWorld-ap-southeast-1 pytest tests/integration/

CloudFront / S3 (tests/integration/test_frontend.py) — fetches the CloudFront distribution URL from the frontend stack outputs and verifies that the index page is served, config.json contains the injected API URL, HTTPS is enforced, security headers are present, and unknown paths fall back to index.html (SPA routing). The frontend stack name is read from AWS_FRONTEND_STACK_NAME in pyproject.toml (defaults to HelloWorldFrontend-us-east-1). Override for a different region:

AWS_FRONTEND_STACK_NAME=HelloWorldFrontend-ap-southeast-1 pytest tests/integration/

If either stack is not deployed, its tests skip automatically rather than failing — so the default pytest run stays green without a live deployment. Other test environment variables are configured in pyproject.toml via pytest-env (see the env key under [tool.pytest.ini_options]).

All test environment variables are centralized in pyproject.toml rather than scattered across test files. Note that POWERTOOLS_IDEMPOTENCY_DISABLED=true is only active during test runs — in production, this env var is not set, so idempotency is fully active against the DynamoDB table.

python -m pytest tests/integration -v
# Shortcut: make test-integration

Timeout

Every test has a 30-second timeout enforced via timeout = 30 in pyproject.toml. Tests that exceed this are terminated and marked as failed. To override for a specific test, use the @pytest.mark.timeout(60) decorator.

Test randomization

pytest-randomly shuffles test execution order on every run to catch order-dependent bugs. It activates automatically when installed — no additional configuration needed. The seed is printed at the top of the output. To reproduce a specific order:

python -m pytest tests/ -p randomly -p no:randomly  # disable
python -m pytest tests/ --randomly-seed=12345        # replay a specific seed

Coverage

Coverage runs automatically on every test run. Key flags set in pyproject.toml:

Flag Effect
--cov=lambda Measures coverage for the lambda/ source directory
--cov-branch Tracks branch coverage (not just whether a line executed, but whether all conditional paths did)
--cov-report=term-missing Prints uncovered line numbers in the terminal
--cov-report=html Generates htmlcov/index.html for detailed browsing
--cov-fail-under=100 Fails the run if total coverage drops below 100%
--no-cov-on-fail Skips the coverage report when tests fail (avoids misleading partial output)

To open the HTML report after a test run:

open htmlcov/index.html

Parallel execution

Tests run in parallel automatically via -n auto in addopts (pyproject.toml). pytest-xdist distributes tests across CPU cores. To disable it for debugging:

python -m pytest tests/ -n0

HTML report

An HTML test report (report.html) is generated automatically on every test run via --html=report.html --self-contained-html in addopts (pyproject.toml). Open it in a browser to view detailed results.

Linting and static analysis

This project uses several tools for code quality. Most are configured in pyproject.toml; bandit uses a separate .bandit file.

# Lint with ruff
ruff check .

# Format with ruff
ruff format .
# Shortcut for lint + format: make lint (runs all hooks) or make format (format only)

# Type check with mypy
mypy lambda/ hello_world/
# Shortcut: make typecheck

# Design and complexity checks with pylint
pylint lambda/ hello_world/

# Security scan with bandit
bandit -r lambda/ hello_world/

# Dependency vulnerability audit
pip-audit
# Shortcut for bandit + pip-audit: make security

# Code complexity with radon/xenon
radon cc lambda/ -a
xenon lambda/ -b B -m A -a A

# Run all of the above at once via pre-commit:
pre-commit run --all-files
# Shortcut: make lint

Bandit configuration (.bandit)

Bandit is a security-focused static analyzer that scans Python source code for common vulnerabilities. Its configuration lives in .bandit rather than pyproject.toml because the pre-commit bandit hook reads YAML config files by convention.

The .bandit file specifies which directories to exclude from scanning:

Directory Reason excluded
tests/ Test code uses assert, hardcoded strings, and other patterns that trigger false positives
cdk.out/ CDK-generated CloudFormation output — not code you write or can fix
.venv/ Third-party packages — vulnerabilities here are caught by pip-audit instead

Everything outside these directories — lambda/ and hello_world/ — is scanned. That is the code you own and ship.

pyproject.toml configuration

All tool configuration is consolidated in pyproject.toml. Here is a summary of the key settings in each section:

[tool.ruff]

Setting Value Purpose
target-version py312 Enables Python 3.12-specific lint rules and syntax modernization
line-length 120 Maximum line length enforced by the formatter
dummy-variable-rgx ^(_+|...)$ Allows _-prefixed variables to be unused without triggering a lint warning

[tool.ruff.lint]

Ruff is configured with a broad set of rule groups. Each group targets a specific class of issue:

Code Plugin What it catches
E / W pycodestyle Style errors and warnings
F pyflakes Undefined names, unused imports
I isort Import ordering
C flake8-comprehensions Inefficient list/dict/set comprehensions
B flake8-bugbear Likely bugs and design issues
S flake8-bandit Security anti-patterns
UP pyupgrade Modernize syntax to the target Python version
SIM flake8-simplify Suggest simpler code patterns
RUF ruff-specific Ruff's own opinionated rules
T20 flake8-print Catches print() calls — use Powertools Logger instead
PT flake8-pytest-style Enforces pytest conventions (fixtures, raises, etc.)
N pep8-naming Naming conventions (snake_case, PascalCase, SCREAMING_SNAKE)
RET flake8-return Unnecessary else after return, redundant return values

[tool.mypy]

Setting Purpose
warn_return_any Warns when a typed function returns Any, which often masks missing type coverage
warn_unused_ignores Warns when a # type: ignore comment is no longer needed, preventing stale suppression comments
disallow_untyped_defs Every function must have complete type annotations
check_untyped_defs Type-checks function bodies even if the function itself lacks annotations
no_implicit_optional f(x: str = None) does not implicitly mean Optional[str] — must be explicit
ignore_missing_imports Suppresses errors for third-party packages without type stubs (e.g. aws-lambda-powertools)
show_error_codes Prints [error-code] next to each error — required to write precise # type: ignore[code] comments

[tool.pylint.design]

Structural complexity thresholds. Pylint fails if any function or class exceeds these limits. Complexity is also enforced by the xenon pre-commit hook (which uses radon under the hood).

Threshold Value What it limits
max-args 8 Parameters per function
max-locals 25 Local variables per function
max-returns 6 Return statements per function
max-branches 12 Branches (if/for/while/try) per function
max-statements 50 Statements per function body
max-attributes 10 Instance attributes per class

[tool.pytest.ini_options]

Key flags in addopts:

Flag Purpose
-ra Prints a short summary of all non-passed tests (failures, errors, skipped) at the end
--cov=lambda Measures coverage for the lambda/ directory
--cov-branch Tracks branch coverage — not just whether a line ran, but whether all conditional paths did
--cov-fail-under=100 Fails the run if total coverage drops below 100%
--no-cov-on-fail Skips coverage reporting when tests fail (avoids misleading partial results)
-n auto Runs tests in parallel across all available CPU cores (pytest-xdist)

log_cli = true and log_cli_level = "WARNING" stream log output in real time during the test run, showing only WARNING and above to reduce noise.

Security

Security is enforced at three layers, each covering a different surface area:

Layer Tool What it scans When it runs
Source code bandit lambda/ and hello_world/ for security anti-patterns (hardcoded secrets, shell injection, unsafe deserialization, etc.) Pre-commit hook on every commit; CI quality job
Dependencies pip-audit All three requirements files for packages with known CVEs Pre-commit hook on every commit; weekly Dependency Audit workflow
Infrastructure cdk-nag CDK stacks against AWS Solutions, Serverless, and NIST 800-53 R5 rules cdk synth — findings are printed and fail synthesis if unsuppressed

These tools are complementary — no single one covers all three surfaces. Bandit catches code-level issues, pip-audit catches supply chain issues, and cdk-nag catches infrastructure misconfigurations.

Detecting deprecated APIs

Deprecated APIs are easy to ignore because they keep working — until the next major release removes them. There is no single command that catches every kind of deprecation, so this project uses a combination of approaches. Each one targets a different layer:

# Approach Catches How to run
1 CDK API deprecations Deprecated CDK properties or methods used by any stack (e.g. FunctionOptions#logRetentionlogGroup) make cdk-deprecations (greps cdk synth output for deprecated)
2 cdk notices AWS-published advisories about the CDK toolchain itself — CVEs, deprecated CDK versions, upcoming breaking changes make cdk-notices
3 Python DeprecationWarning in tests Deprecated stdlib or third-party API calls hit by your tests (boto3, Powertools, etc.) Temporarily add filterwarnings = ["error::DeprecationWarning"] to [tool.pytest.ini_options] in pyproject.toml, run pytest, then revert. Useful as a one-shot audit but too noisy to leave on permanently.
4 Ruff UP (pyupgrade) Deprecated Python syntax — e.g. typing.Listlist, Optional[X]X | None Already enabled in [tool.ruff.lint] select. Runs on every make lint and on every commit via the pre-commit hook.
5 pip list --outdated Version drift — packages that are multiple major versions behind are likely calling deprecated APIs pip list --outdated

The first two are CDK-specific, the next two are Python-specific, and the last one is a general health check across all dependencies. None of them are mutually exclusive.

cdk synth no longer passes --no-notices (it used to, to keep CI output clean), so notices and CDK API deprecation warnings now print on every synth in both local and CI runs.

Commit message convention

This project follows Conventional Commits. Format:

type: short description
Type When to use
feat A new feature
fix A bug fix
docs Documentation changes only
chore Maintenance tasks that don't affect functionality (lock files, Makefile, LICENSE)
ci Changes to CI/CD configuration (GitHub Actions, pre-commit)
test Adding or updating tests
refactor Code restructuring that neither fixes a bug nor adds a feature
build Changes to the build system or dependencies

Pre-commit hooks

Pre-commit runs a chain of hooks automatically on every git commit. Hooks are defined in .pre-commit-config.yaml. Set it up once after cloning:

pre-commit install

To run all hooks manually without committing (useful before pushing or after changing config):

pre-commit run --all-files

Hook reference

Hook Source What it does
ruff astral-sh/ruff-pre-commit Lints and auto-fixes code (runs before formatting)
ruff-format astral-sh/ruff-pre-commit Formats code (equivalent to black)
mypy mirrors-mypy Static type checking on lambda/ and hello_world/ (excludes app.py and tests/)
bandit PyCQA/bandit Security-focused static analysis on lambda/ and hello_world/
pylint local Design and complexity checks on non-test, non-docs Python files
trailing-whitespace pre-commit-hooks Removes trailing whitespace
end-of-file-fixer pre-commit-hooks Ensures every file ends with a newline
check-yaml pre-commit-hooks Validates YAML syntax
check-json pre-commit-hooks Validates JSON syntax
xenon local Enforces cyclomatic complexity thresholds on lambda/ (max absolute: B, module: A, average: A)
pip-audit local Scans all installed dependencies for known CVEs (runs on every commit)

GitHub Actions

Four workflows are configured:

Workflow Trigger What it does
CI Push / PR to main Three jobs: pre-commit hooks (quality), pytest unit tests (test), CDK synth + stack assertion tests (cdk-check)
Docs Push to main Builds Sphinx docs and deploys to GitHub Pages
Dependency Audit Every Monday 9am UTC Runs pip-audit across all requirements files
Dependabot Auto-merge Dependabot PRs Approves and auto-merges GitHub Actions version updates when CI passes

All three CI jobs must pass before anything can merge to main (branch protection).

The CI installs dependencies with pip-sync to match the local dev workflow exactly:

  • quality job: pip-sync requirements.txt
  • test job: pip-sync tests/requirements.txt lambda/requirements.txt
  • cdk-check job: pip-sync requirements.txt (CDK) + pip install pytest pytest-mock (test runner, added separately to avoid the attrs version conflict between CDK and Lambda dependencies) + CDK CLI via npm install -g aws-cdk

The cdk-check job runs cdk synth to catch unsuppressed cdk-nag findings, then runs tests/cdk/test_stacks.py which uses aws_cdk.assertions.Template to verify key security properties of each synthesized stack (KMS encryption, DynamoDB PITR, API Gateway caching, CloudFront TLS version, etc.). These tests live under tests/cdk/ rather than tests/unit/ so the unit-test autouse fixture (which mocks Powertools internals) does not apply — the cdk-check job intentionally does not install Powertools to avoid the attrs version conflict. Asset bundling (Docker) is skipped via the aws:cdk:bundling-stacks context key so the job runs without Docker build time.

Dependabot

Dependabot is configured in .github/dependabot.yml to check for updates every Monday across two ecosystems:

Ecosystem What it checks Auto-merge?
github-actions Workflow YAML for newer action versions (e.g. actions/checkout@v4v5) Patch and minor updates auto-merge via the dependabot-auto-merge workflow once CI passes; major updates require human review
pip All three Python requirements tiers (lambda/, tests/, /) — regenerates lock files with hashes in place No — pip PRs are held for human review

For GitHub Actions patch and minor updates, the dependabot-auto-merge workflow:

  1. Confirms the PR is a GitHub Actions ecosystem update
  2. Checks that dependabot/fetch-metadata reports the update type as version-update:semver-patch or version-update:semver-minor
  3. Approves the PR
  4. Enables auto-merge — GitHub merges it automatically once CI passes

Major updates (e.g. actions/upload-artifact@v4 → @v7) intentionally fall through to manual review because they can contain breaking input/output changes and warrant a human glance at the changelog.

If CI fails on a Dependabot PR (any ecosystem), it stays open for investigation rather than merging.

Repo setting required for auto-merge to work. GitHub repositories ship with GitHub Actions blocked from approving pull requests by default. Until this is changed, the dependabot-auto-merge workflow will fail with GraphQL: GitHub Actions is not permitted to approve pull requests and even GitHub Actions PRs must be merged manually. Enable it once under Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests". This only needs to be done a single time per repo. Leave the Workflow permissions radio set to Read repository contents and packages permissions (the safer default) — every workflow in this repo declares its own explicit permissions: block, so the elevated Read and write permissions default is not needed. See "Least-privilege workflow permissions" in the supply-chain hardening section below.

Python (pip) updates and the pip-tools constraint chain

The three Python requirements tiers are chained together via pip-compile constraint files:

lambda/requirements.in  →  lambda/requirements.txt
                           ↓ (-c constraint)
tests/requirements.in   →  tests/requirements.txt
                           ↓ (-c constraint)
requirements.in         →  requirements.txt

When a package shared across tiers changes version, all downstream lock files must be recompiled in order (lambda → tests → dev). The make compile target does this in one step.

Dependabot handles each directory independently and does not automatically re-run the downstream compiles. If Dependabot bumps a package in lambda/requirements.in, it regenerates lambda/requirements.txt correctly — but tests/requirements.txt and requirements.txt may still reference stale pins from the old lambda file. In practice, this only matters when the bumped package appears in the transitive tree of the downstream tiers.

Grouped updates collapse the PR volume. Each pip ecosystem entry in dependabot.yml defines groups: that bundle related packages into a single PR — aws-*/boto*/botocore always update together, pytest and its plugins update together, all other patch bumps roll into one weekly "patches" PR. Within a group, Dependabot regenerates the entire lock file in one shot, so the cross-package version skew that would otherwise hit boto3+botocore+aws-lambda-powertools (when each is bumped in isolation) cannot happen inside a single tier. Major and minor bumps that are not patches still get individual PRs so each changelog can be reviewed on its own.

Case 1 — single package confined to one tier. When a Dependabot PR bumps a package that only appears in one directory (e.g. ruff in /, pytest in /tests), merging it directly is enough — nothing downstream depends on it. These PRs go green on CI and can be squash-merged as-is.

Case 2 — a package that cascades down the chain. When Dependabot bumps a package in lambda/requirements.in that is also transitively present in tests/ or /, its PR regenerates only its own lock file — the downstream lock files still reference the stale pin. The 30-second fix is to recompile the downstream tiers locally:

gh pr checkout <PR-number>
make compile
git add lambda/requirements.txt tests/requirements.txt requirements.txt
git commit -m "chore: recompile downstream lock files"
git push

Case 3 — cross-cutting packages that Dependabot splits across directories. Packages like boto3 and botocore live as top-level pins in both lambda/requirements.in and tests/requirements.in. Dependabot opens one PR per directory (e.g. boto3 in lambda/, botocore in tests/), but pip-sync tests/requirements.txt lambda/requirements.txt in the CI test job refuses to install mismatched versions — so neither PR can land in isolation and both fail CI. gh pr checkout on either one doesn't fix the other.

The fix is to land them atomically from a single local commit rather than through Dependabot PRs at all:

# Close the stuck Dependabot PRs (they cannot be rebased into a consistent state)
gh pr close <boto3-PR> --comment "Superseded by local recompile"
gh pr close <botocore-PR> --comment "Superseded by local recompile"

# Bump the pin in each .in file, then recompile each tier in order
# (editing lambda/requirements.in and tests/requirements.in to the new version)
pip-compile --generate-hashes --upgrade-package boto3 --upgrade-package botocore \
    lambda/requirements.in -o lambda/requirements.txt
pip-compile --generate-hashes --allow-unsafe --upgrade-package boto3 --upgrade-package botocore \
    tests/requirements.in -o tests/requirements.txt
pip-compile --generate-hashes --allow-unsafe --upgrade-package boto3 --upgrade-package botocore \
    requirements.in -o requirements.txt

git add lambda/requirements.in lambda/requirements.txt tests/requirements.in tests/requirements.txt requirements.txt
git commit -m "build(deps): bump boto3 and botocore across all three lockfiles"
git push

Useful Dependabot commands. When a PR becomes stale relative to main (shown as BEHIND in gh pr view), comment @dependabot rebase on the PR to trigger a fresh rebase and CI run. Other useful commands include @dependabot recreate (regenerates the PR from scratch) and @dependabot ignore this version (skip a specific release). The full command list is at https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates.

We intentionally leave the downstream-recompile step manual rather than automating it with a pull_request_target workflow: the manual path is ~30 seconds a few times a month, and a pull_request_target workflow that pushes back to PR branches carries a non-trivial security surface that is not worth the convenience trade-off for a solo reference project. If this repo ever takes external contributions, the calculus changes and the workflow becomes worth building.

attrs is pinned and ignored. The attrs package has an unresolvable version conflict between CDK (25.4.0) and Lambda Powertools (26.1.0). Dependabot is configured to ignore attrs in all three directories so it does not open unresolvable upgrade PRs — see the "attrs version conflict" note in Design decisions.

Supply-chain hardening

This repo layers six defenses against the supply-chain attack patterns described in GitGuardian's Renovate & Dependabot: the new malware delivery system:

1. Release cooldown. Every ecosystem in dependabot.yml carries a cooldown: block that makes Dependabot wait a few days after a release before opening a PR. Fresh releases are the window in which malicious versions (tag hijacks, compromised maintainer accounts, typo-squats, the xz-utils/nx/tj-actions/changed-files class of incidents) typically get caught and yanked. The tiered schedule is 3 days for patches, 7 for minors, 14 for majors — larger jumps wait longer to let bugs surface.

2. SHA-pinned GitHub Actions. Every uses: reference in .github/workflows/ is pinned to a 40-character commit SHA with the version in a trailing comment:

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2

Tag references are mutable — a compromised maintainer account can rewrite v6 to point at malicious code, and every workflow that said @v6 instantly runs the malicious version on the next trigger. This is exactly what happened to tj-actions/changed-files in March 2025, where attackers rewrote the v35 and v38 tags to exfiltrate CI secrets from thousands of repos within an hour. SHA pins are immutable — a re-tagged release becomes a new commit, and our pin simply ignores it until a human updates it. Dependabot still opens update PRs for SHA-pinned actions; the diff replaces both the SHA and the version comment in one shot.

3. Hash-locked pip installs. All three lock files are generated with pip-compile --generate-hashes, so every dependency is pinned to a SHA256. Even if an attacker uploads a malicious version under an existing version number (e.g. after yanking the legitimate one), pip install refuses to install it because the hash does not match.

4. Restricted auto-merge. Auto-merge is scoped to patch and minor GitHub Actions updates only (see above). pip updates never auto-merge, regardless of the update type, because they ship to production Lambda. Majors in either ecosystem require human review.

5. Local pip cooldown on make upgrade. Dependabot's cooldown protects every dependency change that lands via a PR, but make upgrade runs locally on a developer laptop and bypasses Dependabot entirely. The Makefile mirrors the same defense by passing pip's --uploaded-prior-to flag to pip-compile --upgrade, filtering out any package version uploaded in the last COOLDOWN_DAYS days (default 7). Override at the command line if you need to pull a fresher version: make upgrade COOLDOWN_DAYS=1. The cooldown is intentionally only applied to upgrade, not compile. compile reproduces decisions already encoded in the .in files and the existing lockfile — it cannot introduce a brand-new version, so cooldown is unnecessary and would actively conflict with freshly-bumped pins. upgrade is the only target where new versions enter the project and is the only place a fresh malicious release can land. Disable entirely with COOLDOWN_DAYS=0.

6. Least-privilege workflow permissions. Every workflow under .github/workflows/ declares an explicit permissions: block scoped to exactly what it needs, and the repo-level default (Settings → Actions → General → Workflow permissions) is set to read rather than write. The combination means that if a malicious dependency runs inside a CI job, the GITHUB_TOKEN it sees has the minimum authority necessary — ci.yml, dependency-audit.yml, and docs.yml's build job all run with contents: read and nothing else. Only two places escalate beyond read: docs.yml's deploy job adds pages: write + id-token: write for GitHub Pages OIDC deployment, and dependabot-auto-merge.yml adds contents: write + pull-requests: write so it can approve and merge Dependabot PRs (and is gated on github.actor == 'dependabot[bot]' plus a patch/minor update-type check). Permissions are declared at the job level wherever a workflow has heterogeneous needs (docs.yml's build vs deploy) and at the workflow level otherwise. The repo-level read default acts as defense-in-depth: if any future workflow is added without an explicit permissions: block, it inherits read instead of write-everything. The repo separately keeps can_approve_pull_request_reviews enabled — that toggle is independent of the default and is required for the auto-merge workflow's gh pr review --approve call to succeed.

What's intentionally not implemented: honeytokens. The article also recommends honeytokens as a detection layer, which this repo deliberately skips. A honeytoken is a fake credential — typically an AWS access key or GitHub token that looks completely real but is registered with a canary service (Thinkst, GitGuardian, AWS canarytokens.org). Nothing legitimate ever authenticates with it, so any use is by definition either an attacker or a misconfigured script that shouldn't exist. When someone tries it, the canary service alerts the owner with the timestamp, source IP, and which specific token fired — that last detail reveals where the attacker read it from (CI logs, a specific repo clone, a leaked .env, etc.), which makes incident scoping much faster.

Honeytokens are specifically effective against the CI-exfiltration pattern this hardening section guards against: a malicious dependency running inside a CI runner typically sweeps environment variables and filesystem paths for anything that looks like a credential and pipes it to an attacker-controlled endpoint. If a honeytoken is reachable from the same runner (e.g. planted in .github/workflows/ as a fake DEPLOY_KEY secret, or embedded in a committed .env.example file), the exfiltration tooling scoops it up alongside the real credentials and the alert fires within seconds of the breach — weeks before an attacker would otherwise tip their hand by actually using the stolen credentials.

Everything else in this section is prevention; honeytokens are detection. They do not stop attacks, they tell you an attack happened. The reason this repo skips them is not value but operational overhead: honeytokens are easy to plant (canarytokens.org generates one in about 30 seconds) but the alert routing is the real cost. You need somewhere for the alert to land (email you actually read, a Slack channel you watch, a pager), and you need a runbook for what to do when it fires. For a solo reference project with no production data, no customer PII, and CI secrets that can be rotated in 30 seconds, that plumbing is not worth standing up. For any repo running real workloads — production AWS accounts, customer data, deploy keys to anything that ships to users — honeytokens are a high-signal, low-noise addition that pays for itself the first time one fires, and GitGuardian's free ggshield CLI ships a generator that integrates with their alert console.

What's intentionally not implemented: machine inventory. The article recommends maintaining an inventory of every machine that runs unattended pip install / npm install, because each one is an exfiltration surface if a malicious package lands. At org scale this matters — dozens of build boxes, dev laptops with auto-updaters, and shared CI fleets each multiply the blast radius. For this repo the inventory is two items: one developer laptop and GitHub-hosted runners (which are ephemeral and Microsoft's problem). Writing that down as a doc would add a file to keep in sync with zero defensive value at this scale, so it is deliberately omitted. If this pattern is extended to a team or org repo, the inventory should be revisited and probably codified.

What's intentionally not implemented: a post-compromise rotation runbook. The article also recommends a written checklist for "assume a malicious dep ran in CI — what do I rotate, in what order, how fast?" The value of writing it down ahead of time is that during an actual incident, you are panicked and will forget steps. This repo deliberately skips the runbook because it currently has nothing to rotate: the only secret in use is the auto-scoped per-run GITHUB_TOKEN, which expires when each job ends. There are no AWS deploy keys, no API tokens, no secrets.* entries with real credentials. A runbook today would read "revoke nothing, there's nothing to revoke." The moment a real secret is added to this repo (an AWS deploy role, a Sentry DSN, anything in secrets.*), this section should become a runbook with the rotation order, who to notify, and the commands to run.

Why not Renovate?

Renovate is an alternative to Dependabot that handles multi-file Python setups more gracefully. Specifically:

  • Renovate understands pip-compile constraint chains natively and recompiles downstream lock files in the correct order automatically — no manual make compile step, no bespoke workflow.
  • Renovate supports richer grouping (e.g., "group all AWS packages into one PR") and auto-merge rules scoped by update type (patch/minor/major).
  • Renovate runs as a GitHub App, so it is zero-infrastructure.

This project uses Dependabot rather than Renovate because Dependabot is the GitHub-native default, already integrated into the repo for GitHub Actions updates, and the manual make compile step is a minor cost at the current scale. If you extend this pattern to a production repo with more frequent Python churn — or if you want auto-merge for non-major pip updates — Renovate is the lower-friction choice and worth evaluating. The configuration lives in renovate.json at the repo root; the GitHub App handles the rest.

CDK security checks

All three stacks use cdk-nag with three rule packs applied to every resource at synth time. Any finding that is not suppressed fails cdk synth — infrastructure misconfigurations are caught before deployment, not after.

Checks run automatically on every cdk synth and cdk deploy. There is no separate command needed.

Rule packs in use

Pack Import Focus
AwsSolutionsChecks from cdk_nag import AwsSolutionsChecks AWS general best practices — IAM, encryption, logging, resilience
ServerlessChecks from cdk_nag import ServerlessChecks Serverless-specific rules — Lambda DLQ, tracing, memory, throttling
NIST80053R5Checks from cdk_nag import NIST80053R5Checks NIST 800-53 Rev 5 controls — the current standard used by many enterprises and federal workloads

Other available rule packs

cdk-nag ships additional packs that are not enabled in this project. They can be added by importing and applying them the same way as the packs above:

Pack Import When to use
NIST80053R4Checks from cdk_nag import NIST80053R4Checks NIST 800-53 Rev 4 — superseded by R5; only use if your compliance framework specifically requires R4
HIPAASecurityChecks from cdk_nag import HIPAASecurityChecks HIPAA Security Rule — required when handling protected health information (PHI)
PCIDSS321Checks from cdk_nag import PCIDSS321Checks PCI DSS 3.2.1 — required when handling payment card data

Full rule documentation: github.com/cdklabs/cdk-nag/blob/main/RULES.md

Suppressions

Not every rule is appropriate for a sample application. Where a rule has been intentionally suppressed, the suppression lives in the stack file in either NagSuppressions.add_stack_suppressions (stack-wide) or NagSuppressions.add_resource_suppressions/add_resource_suppressions_by_path (targeted to a specific resource). Each entry includes a reason field explaining why it was suppressed rather than fixed.

Stack-level suppressions are reserved for findings that are genuinely stack-wide (e.g., no custom domain, no VPC by design). Everything else is suppressed at the resource level to keep the blast radius of each suppression as small as possible. CDK-managed singleton Lambdas (BucketDeployment provider, LogRetention, S3AutoDeleteObjects, AwsCustomResource) share a common suppression list defined in hello_world/nag_utils.py (CDK_LAMBDA_SUPPRESSIONS) and are targeted by their stable CDK construct IDs using add_resource_suppressions_by_path. AwsSolutions-IAM5 suppressions on HelloWorldFunction use the applies_to parameter to scope them to specific wildcard actions and resources rather than suppressing all IAM5 findings on the role.

What is encrypted with CMK: All CloudWatch log groups (Lambda, API Gateway access, API Gateway execution, WAF, auto-delete Lambda), DynamoDB, and the S3 frontend bucket use AWS KMS customer-managed keys with annual key rotation enabled. The S3 access logging bucket uses SSE-S3 because the S3 log delivery service does not support KMS-encrypted target buckets. SSM parameters cannot use CMK (CloudFormation limitation — SecureString is not supported). AppConfig hosted configurations use AWS-managed keys (no CMK option in CDK).

Current suppressions across all stacks:

Rule Stack Scope Why suppressed
AwsSolutions-APIG2 Backend Stack Request validation not needed for sample app
AwsSolutions-APIG3 Backend Stack WAF applied at CloudFront, not directly on API Gateway
AwsSolutions-APIG4 Backend Stack No authorizer — auth is out of scope for this sample
AwsSolutions-COG4 Backend Stack No Cognito authorizer — same as APIG4
AwsSolutions-IAM4 Backend, Frontend Per-resource (CDK singletons + HelloWorldFunction) CDK-managed Lambda roles use AWS managed policies; not configurable by the caller
AwsSolutions-IAM5 Backend, Frontend, WAF Per-resource (with applies_to) Wildcard permissions scoped to specific actions — X-Ray, KMS GenerateDataKey*/ReEncrypt*, CDK custom resource Resource::*
AwsSolutions-L1 Backend, Frontend Per-resource (CDK singletons) CDK-managed Lambda runtimes are not configurable; HelloWorldFunction is pinned to Python 3.12
AwsSolutions-S1 Frontend Resource (log bucket) The access log bucket itself — logging to itself would be circular
AwsSolutions-CFR1/3 Frontend Stack CloudFront access logging not enabled for sample app
AwsSolutions-CFR4 Frontend Stack Default CloudFront certificate — no custom domain for sample app
Serverless-LambdaDLQ Backend, Frontend Per-resource (CDK singletons) CDK-managed Lambdas — DLQ is not configurable; HelloWorldFunction is synchronously invoked via API Gateway
Serverless-LambdaDefaultMemorySize Backend, Frontend Per-resource (CDK singletons) CDK-managed singleton Lambdas — memory is not configurable; HelloWorldFunction uses explicit 256 MB
Serverless-LambdaLatestVersion Backend, Frontend Per-resource (CDK singletons) CDK-managed Lambda runtimes are not configurable
Serverless-LambdaTracing Backend, Frontend Per-resource (CDK singletons only) CDK-managed provider Lambdas do not expose tracing config; HelloWorldFunction passes natively
Serverless-APIGWDefaultThrottling Backend Stack Custom throttling not configured for sample app
CdkNagValidationFailure Backend Stack Intrinsic function reference prevents Serverless-APIGWStructuredLogging from validating
NIST.800.53.R5-LambdaConcurrency Backend, Frontend Per-resource (CDK singletons) CDK-managed singleton Lambdas — concurrency is not configurable
NIST.800.53.R5-LambdaDLQ Backend, Frontend Per-resource (CDK singletons) CDK-managed Lambdas — DLQ is not configurable; HelloWorldFunction is synchronously invoked
NIST.800.53.R5-LambdaInsideVPC Backend, Frontend Per-resource (CDK singletons) CDK-managed singleton Lambdas — VPC is not configurable
NIST.800.53.R5-IAMNoInlinePolicy Backend, Frontend, WAF Per-resource CDK-generated inline policies on singleton service roles — not directly configurable
NIST.800.53.R5-APIGWAssociatedWithWAF Backend Stack WAF applied at CloudFront, not directly on API Gateway
NIST.800.53.R5-APIGWSSLEnabled Backend Stack Client-side SSL certificates not required for sample app
NIST.800.53.R5-DynamoDBInBackupPlan Backend Stack AWS Backup plan not configured; PITR is enabled for point-in-time recovery
NIST.800.53.R5-S3BucketLoggingEnabled Frontend Resource (log bucket) The access log bucket itself — logging to itself would be circular
NIST.800.53.R5-S3BucketReplicationEnabled Frontend Stack + Resource Static assets are redeployable; replication not needed
NIST.800.53.R5-S3BucketVersioningEnabled Frontend Stack + Resource Static assets are redeployable via cdk deploy; versioning not needed
NIST.800.53.R5-S3DefaultEncryptionKMS Frontend Resource (log bucket only) S3 log delivery service does not support KMS target buckets; SSE-S3 required

Rules that were previously suppressed and have since been implemented are removed from this list. If you add a suppression, include a clear reason and consider whether the finding represents a genuine gap worth addressing in production.

Frontend stack

The frontend is split across two CDK stacks — HelloWorldWafStack and HelloWorldFrontendStack — intentionally decoupled from the backend. This allows the frontend to be deployed and destroyed independently of the API, and demonstrates the standard CDK multi-stack and cross-region reference pattern.

Architecture

Browser → CloudFront → S3 (private bucket)
               ↓
        WAF WebACL (us-east-1, always)

The browser calls GET /hello directly from JavaScript against the API Gateway URL — CloudFront only serves static assets, it does not proxy API requests.

Three-stack design and cross-region support

This project uses three stacks, not two. WAF lives in its own stack because CloudFront-scoped WAF WebACLs are an AWS hard requirement to exist in us-east-1 — even if every other resource is in a different region. By isolating WAF into HelloWorldWafStack, the backend and frontend can be deployed to any region without duplicating the WAF or violating the constraint.

Each regional deployment gets its own set of three independently named stacks:

Stack Region Contents
HelloWorldWaf-{region} Always us-east-1 WAF WebACL with all rules
HelloWorld-{region} Configurable Lambda, API Gateway, DynamoDB, SSM, AppConfig
HelloWorldFrontend-{region} Configurable S3, CloudFront (references WAF ARN)

Deploying to us-east-1 (default):

cdk deploy --all

Deploying to a different region:

cdk deploy --all -c region=ap-southeast-1

WAF stays in us-east-1 (always). The backend and frontend deploy to the target region. CDK wires the WAF ARN across regions automatically — no manual steps.

Destroying a specific regional deployment:

cdk destroy --all -c region=ap-southeast-1

This tears down only the Singapore stack set (HelloWorldWaf-ap-southeast-1, HelloWorld-ap-southeast-1, HelloWorldFrontend-ap-southeast-1). Any other regional deployments are unaffected.

WAF cost note — Each regional deployment provisions its own WAF WebACL at $5/month. This keeps deployments fully independent, which is the right default for a reference architecture. In a production setup with multiple long-lived environments, you could share a single HelloWorldWaf stack across all regions and pass its ARN to each frontend stack, eliminating the per-deployment cost. That optimization is intentionally deferred here in favour of deployment independence.

How cross-region references work

When the frontend stack is in a different region from the WAF stack, CDK cannot pass the WAF ARN as a direct CloudFormation output (outputs only work within a single region). Instead, CDK uses cross_region_references=True on the frontend stack to bridge the value automatically:

  1. During cdk deploy, CDK writes the WAF ARN into an SSM Parameter in us-east-1
  2. A CDK-managed custom resource in the frontend stack's region reads that SSM parameter at deploy time
  3. The WAF ARN is resolved and attached to the CloudFront distribution

This is entirely transparent — you pass waf.web_acl_arn in app.py just like any other stack property. The SSM parameters are CloudFormation-managed and are cleaned up on cdk destroy.

The backend exposes api_url as a stack property. The frontend stack injects it into config.json at deploy time via BucketDeployment. The browser fetches /config.json at runtime so the API URL is never hardcoded in source.

The static assets themselves live in the frontend/ directory at the project root. Currently this is just a single index.html that fetches config.json and calls the API — replace it with a built SPA bundle (e.g. the dist/ output from a Vite or Next.js export build) and the existing BucketDeployment will pick it up automatically.

S3 bucket

The bucket is fully private — no public access of any kind. CloudFront reaches it exclusively via Origin Access Control (OAC), the current AWS-recommended successor to OAI. The bucket is encrypted with SSE-KMS (customer-managed key with annual rotation), has SSL enforced, server access logging enabled to a dedicated log bucket, versioning disabled (git is the source of truth), and auto_delete_objects=True so cdk destroy empties and deletes it cleanly.

The access log bucket uses SSE-S3 rather than SSE-KMS — the S3 log delivery service does not support writing to KMS-encrypted target buckets.

CloudFront distribution

Setting Value Why
Viewer protocol Redirect HTTP → HTTPS Prevents plaintext traffic
Minimum TLS TLS 1.2 (2021 policy) Drops obsolete TLS 1.0/1.1
Cache policy CACHING_OPTIMIZED S3 static assets — aggressive caching is correct
Response headers SECURITY_HEADERS managed policy Adds HSTS, X-Frame-Options, X-Content-Type-Options, etc.
Default root object index.html Serves the app at /
Error responses 403/404 → index.html (200) Supports SPA client-side routing
Cache invalidation /* on every deploy New assets served immediately

WAF rules

The WebACL sits in front of CloudFront and inspects every request before it reaches S3. Four rules are active, evaluated in priority order:

Priority Rule What it blocks
0 AWSManagedRulesAmazonIpReputationList Known malicious IPs — botnets, scanners, TOR exits
1 AWSManagedRulesCommonRuleSet OWASP Top 10 web exploits
2 AWSManagedRulesKnownBadInputsRuleSet Requests containing SQLi, XSS, and exploit payloads
3 RateLimitPerIP (custom) Blocks any single IP exceeding 1,000 requests per 5 minutes

All rules emit CloudWatch metrics and sampled requests, so WAF activity is visible in the console without additional configuration.

WAF access logs are written to a CloudWatch Logs log group named aws-waf-logs-{stack_name} (the aws-waf-logs- prefix is an AWS requirement). The log group is KMS-encrypted with a customer-managed key and has 1-week retention.

The WAF WebACL lives in HelloWorldWafStack which is always pinned to us-east-1. This is an AWS hard constraint for CloudFront-scoped WebACLs. The cross-region reference pattern described above handles wiring the ARN to CloudFront automatically regardless of where the frontend stack is deployed.

Resource cleanup

Every resource in HelloWorldWafStack and HelloWorldFrontendStack has RemovalPolicy.DESTROY, including all CloudWatch log groups. cdk destroy --all leaves nothing behind in any region.

Note: CDK creates an internal singleton Lambda to empty the S3 bucket before deletion (Custom::S3AutoDeleteObjects). Its log group is explicitly declared in the stack so CloudFormation owns it and deletes it on destroy — following the same principle as the API Gateway execution log group in the backend stack.

Monitoring

The stack includes a cdk-monitoring-constructs MonitoringFacade that creates a CloudWatch dashboard with Lambda, API Gateway, and DynamoDB metrics out of the box.

Documentation

Project documentation is generated from docstrings and markdown files using Sphinx with MyST-Parser. Source files are in docs/. All five modules are documented: lambda/app.py (Lambda handler), hello_world/hello_world_stack.py (backend), hello_world/hello_world_waf_stack.py (WAF), hello_world/hello_world_frontend_stack.py (frontend), and hello_world/nag_utils.py (shared suppression utilities). Doc builds are best run in CI/CD pipelines or manually before publishing, rather than on every commit.

# Build HTML docs
PYTHONPATH=lambda:. sphinx-build -b html docs docs/_build
# Shortcut: make docs

# Open in browser
open docs/_build/index.html
# Shortcut for build + open: make docs-open

Project dependencies

Dependencies are managed with pip-tools. Each dependency group has a .in file (direct dependencies you maintain) and a .txt file (fully resolved with transitive dependencies and hashes, generated by pip-compile).

  • requirements.in / requirements.txt — CDK, linting, static analysis, and dev tooling (constrained by tests/requirements.txt)
  • tests/requirements.in / tests/requirements.txt — pytest and test plugins (constrained by lambda/requirements.txt)
  • lambda/requirements.in / lambda/requirements.txt — Lambda runtime dependencies (packaged with the function at deploy time)

Constraint files (-c) ensure shared packages like boto3 resolve to the same version across all environments, preventing drift.

To regenerate the lock files after editing a .in file, compile in order (lambda → tests → dev):

pip-compile --generate-hashes lambda/requirements.in -o lambda/requirements.txt
pip-compile --generate-hashes --allow-unsafe tests/requirements.in -o tests/requirements.txt
pip-compile --generate-hashes --allow-unsafe requirements.in -o requirements.txt
# Shortcut: make compile

To upgrade all dependencies:

# Default: blocks any version uploaded to PyPI in the last 7 days (cooldown defense)
make upgrade

# Override the cooldown window
make upgrade COOLDOWN_DAYS=14   # stricter — only versions older than 14 days
make upgrade COOLDOWN_DAYS=1    # near-immediate — only the last 24 hours filtered
make upgrade COOLDOWN_DAYS=0    # disable cooldown entirely

make upgrade passes pip's --uploaded-prior-to flag to pip-compile --upgrade so brand-new PyPI releases (the window in which malicious versions typically get caught and yanked) are not pulled into the lockfiles. See "Local pip cooldown on make upgrade" in the supply-chain hardening section for the full rationale.

To install and keep your venv in sync with dev dependencies:

pip-sync requirements.txt
pip install -r tests/requirements.txt -r lambda/requirements.txt

pip-sync is used for the dev context because it removes stale packages not in the lock file. Test deps are added with pip install -r instead — using pip-sync for both contexts would remove dev packages, corrupting the venv. CI uses separate jobs so each runs pip-sync against a single context cleanly.

lambda/requirements.txt — Lambda runtime

Library Purpose
aws-lambda-powertools[all] Full Powertools suite: Logger, Tracer, Metrics, Event Handler, Idempotency, Parameters, Feature Flags, Validation, and Event Source Data Classes
aws-xray-sdk Required by Powertools Tracer for X-Ray instrumentation
boto3 AWS SDK, version-locked in the deployment package to avoid depending on the Lambda runtime's bundled version

requirements.txt — CDK and dev tooling

Library Purpose
aws-cdk-lib Core CDK framework for defining AWS infrastructure
constructs Base construct library used by CDK
aws-cdk-aws-lambda-python-alpha PythonFunction construct that bundles Lambda dependencies in a container
cdk-monitoring-constructs Auto-generates CloudWatch dashboards and alarms for Lambda and API Gateway
cdk-nag Runs AWS Solutions security checks against the CDK stack during synthesis
ruff Fast Python linter and formatter (configured in pyproject.toml)
mypy Static type checker (configured in pyproject.toml)
pylint Design and complexity checks complementing ruff (configured in pyproject.toml)
bandit Security-focused static analysis (configured in .bandit)
radon Computes code complexity metrics (cyclomatic complexity, maintainability index)
xenon Enforces complexity thresholds, fails if code exceeds limits
pip-audit Scans installed dependencies for known vulnerabilities
pre-commit Git hook framework that runs linters and formatters on each commit
boto3-stubs Type stubs for boto3, enables mypy to type-check AWS SDK calls
sphinx Documentation generator, builds HTML docs from docstrings and markdown (configured in docs/conf.py)
myst-parser Enables Sphinx to use Markdown files alongside reStructuredText
pip-tools Generates fully resolved, hash-verified requirements.txt files from .in source files

tests/requirements.txt — Testing

Library Purpose
pytest Test framework
pytest-env Sets environment variables in pyproject.toml (e.g. AWS_BACKEND_STACK_NAME)
pytest-cov Code coverage reporting
pytest-xdist Parallel test execution with -n auto
pytest-mock Provides mocker fixture for mocking (used for Lambda context in unit tests)
pytest-html Generates HTML test reports
pytest-timeout Enforces per-test time limits (configured in pyproject.toml)
pytest-randomly Randomizes test execution order to catch order-dependent bugs
boto3 AWS SDK, used by integration tests to query CloudFormation stack outputs
requests HTTP client, used by integration tests to call the live API Gateway endpoint

Design decisions and known limitations

cdk.out/ is not committed — this directory contains the synthesized CloudFormation template and bundled Lambda assets generated by cdk synth. It is gitignored because it is always reproducible from source and can be large. Run cdk synth locally to regenerate it before deploying or invoking locally with SAM.

attrs version conflictrequirements.txt (dev) pins attrs==25.4.0 for CDK compatibility, while lambda/requirements.txt pins attrs==26.1.0 for Powertools. These two versions cannot coexist in a single environment. This is why the CI is split into separate quality and test jobs, and why local test deps are installed with pip install -r (additive) rather than pip-sync (destructive).

SSM parameter path is derived from the stack name — the greeting parameter is stored at /{stack_name}/greeting in SSM (e.g. /HelloWorld-us-east-1/greeting), so each regional deployment gets its own parameter automatically. This is intentional for a reference project but would likely be parameterised differently in a production stack.

CORS is open (allow_origin="*") — the Lambda handler configures APIGatewayRestResolver with CORSConfig(allow_origin="*") for simplicity. In production, restrict this to the specific CloudFront domain (e.g., allow_origin="https://d1234.cloudfront.net") and set allow_credentials=True if the API requires cookies or Authorization headers. Leaving CORS open in production allows any origin to call the API from a browser.

Error handling — the handler demonstrates the recommended pattern for production Lambda error handling. Critical downstream failures (SSM) return a 500 via InternalServerError so the API always responds with a meaningful HTTP status rather than a Lambda runtime error. Non-critical failures (AppConfig feature flags) fall back to a safe default rather than failing the whole request. As you extend this project, apply the same pattern to any new downstream calls: decide whether the failure is critical (raise InternalServerError) or non-critical (log a warning, use a default), and add a corresponding unit test for each path.

Explicit resource creation prevents dangling resources — AWS services sometimes create supporting resources outside of CloudFormation. The most common example is CloudWatch log groups: Lambda creates one automatically on first invocation, and API Gateway creates an execution log group (API-Gateway-Execution-Logs_{api-id}/{stage}) whenever execution logging is enabled. Neither is managed by CloudFormation, so neither is deleted when you run cdk destroy — they silently persist and accrue storage costs indefinitely.

Every resource in this stack is declared explicitly in CDK with removal_policy=RemovalPolicy.DESTROY so that cdk destroy leaves nothing behind. When you add new AWS services, check whether they create their own supporting resources (log groups, S3 buckets, parameter store entries, etc.) and declare those explicitly too. The pattern is: if AWS creates it, CDK should own it.

Application Insights dashboard — Application Insights automatically creates a CloudWatch dashboard named after its resource group when auto_configuration_enabled=True. This dashboard is created outside of CloudFormation and cannot be pre-declared in CDK. To ensure it is removed on cdk destroy, the backend stack includes a Lambda-backed custom resource (AppInsightsDashboardCleanup) that calls DeleteDashboards at destroy time, targeting the dashboard by the resource group name.

Note: If you ever rename the Application Insights resource group (e.g., by changing the stack name), the dashboard associated with the old name will be left behind because the old custom resource no longer knows about it. Clean it up manually:

# List Application Insights dashboards
aws cloudwatch list-dashboards --query "DashboardEntries[?contains(DashboardName, 'ApplicationInsights')]"

# Delete the old one
aws cloudwatch delete-dashboard --dashboard-names "ApplicationInsights-<old-resource-group-name>"

Cleanup

To delete the application and all associated AWS resources, run:

cdk destroy

Every resource in the stack — including all three CloudWatch log groups — is configured with RemovalPolicy.DESTROY, so a single cdk destroy leaves no dangling resources and no ongoing AWS costs.

Resources

See the AWS CDK Developer Guide for an introduction to CDK concepts and the CDK CLI.

About

Serverless Hello World with AWS Lambda Powertools, CDK, and comprehensive Python tooling

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors