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 callsapp.synth()lambda/- Code for the application's Lambda functionhello_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 inus-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 Lambdasfrontend/- Static assets (index.html) deployed to the frontend S3 bucketevents/event.json- A sample API Gateway proxy event for local SAM invocationtests/- Unit and integration teststests/conftest.py- Shared test fixtures (API Gateway event, Lambda context, mocks)docs/- Sphinx documentation source filespyproject.toml- Consolidated tool configuration (ruff, mypy, pylint, pytest, coverage).pre-commit-config.yaml- Pre-commit hook definitions (runs on everygit 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 helpto list all targets)LICENSE- Apache 2.0 licenseTODO.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.
The Lambda function in lambda/app.py uses the following Powertools utilities:
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.
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.
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).
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.
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.
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.
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.
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.
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 eventsS3Event— S3 bucket notificationsSQSEvent— SQS messagesDynamoDBStreamEvent— DynamoDB stream recordsEventBridgeEvent— EventBridge eventsSNSEvent,KinesisStreamEvent,CloudWatchLogsEvent, and more
These are available from aws_lambda_powertools.utilities.data_classes and require no extra dependencies.
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 |
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 testNo 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.
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 filesTo use the CDK, you need the following tools.
- Node.js - Required to install the CDK CLI (
npm install -g aws-cdk) - AWS CDK CLI - Install the CDK CLI
- AWS SAM CLI - Install the SAM CLI - Required for local invocation and log tailing
- Python 3 installed
- Finch - Container runtime used for bundling Lambda dependencies and local invocation
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=finchTo 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 --allThe 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 IDWafLogGroupName— CloudWatch log group name for WAF access logs
HelloWorld-{region}:
HelloWorldApiOutput— API Gateway endpoint URL (https://.../Prod/hello)HelloWorldFunctionOutput— Lambda function ARNHelloWorldFunctionIamRoleOutput— Lambda IAM role ARNIdempotencyTableName— DynamoDB table nameGreetingParameterName— SSM parameter pathAppConfigAppName— AppConfig application nameCloudWatchDashboardUrl— Direct link to the CloudWatch monitoring dashboard
HelloWorldFrontend-{region}:
CloudFrontDomainName—https://URL to open in a browserCloudFrontDistributionId— Distribution ID for manual cache invalidationsFrontendBucketName— S3 bucket name for direct asset inspection
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# 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-1cdk lslist all stacks in the appcdk synthemit the synthesized CloudFormation templatecdk deploy --alldeploy all stacks to us-east-1 (default)cdk deploy --all -c region=Xdeploy all stacks to region Xcdk diffcompare deployed stack with current statecdk destroy --alldestroy all stacks in the default region
Synthesize your application to verify the CloudFormation template (requires Finch running):
export CDK_DOCKER=finch
cdk synthYou 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.jsonevents/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/helloNote: Local invocation requires Finch to be running:
finch vm startYou can use the SAM CLI to fetch logs from your deployed Lambda function:
sam logs -n HelloWorldFunction --stack-name "HelloWorld" --tailThis 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.
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 are defined in the tests folder in this project. Make sure dependencies are installed first (see Deploy the application).
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 var — POWERTOOLS_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).
python -m pytest tests/unit -v
# Shortcut: make testTwo 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-integrationEvery 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.
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 seedCoverage 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.htmlTests 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/ -n0An 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.
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 lintBandit 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.
All tool configuration is consolidated in pyproject.toml. Here is a summary of the key settings in each section:
| 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 |
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 |
| 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 |
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 |
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 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.
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#logRetention → logGroup) |
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.List → list, 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.
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 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 installTo run all hooks manually without committing (useful before pushing or after changing config):
pre-commit run --all-files| 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) |
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:
qualityjob:pip-sync requirements.txttestjob:pip-sync tests/requirements.txt lambda/requirements.txtcdk-checkjob:pip-sync requirements.txt(CDK) +pip install pytest pytest-mock(test runner, added separately to avoid theattrsversion conflict between CDK and Lambda dependencies) + CDK CLI vianpm 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 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@v4 → v5) |
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:
- Confirms the PR is a GitHub Actions ecosystem update
- Checks that
dependabot/fetch-metadatareports the update type asversion-update:semver-patchorversion-update:semver-minor - Approves the PR
- 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.
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 pushCase 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 pushUseful 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.
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.2Tag 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.
Renovate is an alternative to Dependabot that handles multi-file Python setups more gracefully. Specifically:
- Renovate understands
pip-compileconstraint chains natively and recompiles downstream lock files in the correct order automatically — no manualmake compilestep, 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.
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.
| 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 |
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
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.
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.
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.
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 --allDeploying to a different region:
cdk deploy --all -c region=ap-southeast-1WAF 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-1This 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
HelloWorldWafstack 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.
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:
- During
cdk deploy, CDK writes the WAF ARN into an SSM Parameter inus-east-1 - A CDK-managed custom resource in the frontend stack's region reads that SSM parameter at deploy time
- 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.
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.
| 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 |
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.
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.
The stack includes a cdk-monitoring-constructs MonitoringFacade that creates a CloudWatch dashboard with Lambda, API Gateway, and DynamoDB metrics out of the box.
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-openDependencies 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 bytests/requirements.txt)tests/requirements.in/tests/requirements.txt— pytest and test plugins (constrained bylambda/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 compileTo 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 entirelymake 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.txtpip-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.
| 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 |
| 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 |
| 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 |
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 conflict — requirements.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>"
To delete the application and all associated AWS resources, run:
cdk destroyEvery 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.
See the AWS CDK Developer Guide for an introduction to CDK concepts and the CDK CLI.