Automating AWS Well-Architected Reviews with Lambda, API Gateway and S3
Running a Well-Architected Review (WAR) manually means hours of clicking through the AWS console, screenshotting configurations and pasting findings into a document. While working with a logistics client, I built a serverless tool that automates the data-gathering half of that work. Here’s how you can build the same thing.
What we’re building
The architecture is intentionally simple — four serverless pieces, zero servers to maintain:
- API Gateway — a single endpoint that triggers a review run
- Lambda — collects resource data across services using the AWS SDK
- S3 — stores the generated findings as structured JSON + Markdown
- CloudWatch — logs and metrics for each run
Step 1: The IAM role
The Lambda needs read-only visibility across the services you want to assess. Start from the managed ReadOnlyAccess policy for a PoC, then narrow it down:
resource "aws_iam_role" "war_collector" {
name = "war-collector-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "readonly" {
role = aws_iam_role.war_collector.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
Security note: in production, replace
ReadOnlyAccesswith a scoped policy listing only theDescribe*/List*/Get*actions you actually call. Least privilege applies to auditing tools too — especially auditing tools.
Step 2: The collector Lambda
The collector walks each service and evaluates it against Well-Architected pillar checks. A trimmed example covering two common findings:
import boto3
import json
from datetime import datetime, timezone
def handler(event, context):
findings = []
# Pillar: Security — public S3 buckets
s3 = boto3.client("s3")
for bucket in s3.list_buckets()["Buckets"]:
name = bucket["Name"]
try:
pab = s3.get_public_access_block(Bucket=name)
config = pab["PublicAccessBlockConfiguration"]
if not all(config.values()):
findings.append({
"pillar": "Security",
"severity": "HIGH",
"resource": name,
"issue": "Public access block is not fully enabled",
})
except s3.exceptions.ClientError:
findings.append({
"pillar": "Security",
"severity": "HIGH",
"resource": name,
"issue": "No public access block configuration found",
})
# Pillar: Reliability — single-AZ RDS instances
rds = boto3.client("rds")
for db in rds.describe_db_instances()["DBInstances"]:
if not db["MultiAZ"]:
findings.append({
"pillar": "Reliability",
"severity": "MEDIUM",
"resource": db["DBInstanceIdentifier"],
"issue": "RDS instance is not Multi-AZ",
})
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"finding_count": len(findings),
"findings": findings,
}
s3.put_object(
Bucket="war-reports-<your-account-id>",
Key=f"reports/{report['generated_at']}.json",
Body=json.dumps(report, indent=2),
ServerSideEncryption="aws:kms",
)
return {"statusCode": 200, "body": json.dumps({"findings": len(findings)})}
Each check maps to a Well-Architected pillar — Security, Reliability, Performance Efficiency, Cost Optimization, Operational Excellence and Sustainability. Start with the checks that hurt your organization most; ours were public buckets, missing Multi-AZ, unattached EBS volumes and untagged resources.
Step 3: Wire up API Gateway
resource "aws_apigatewayv2_api" "war" {
name = "war-automation"
protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "collector" {
api_id = aws_apigatewayv2_api.war.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.collector.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "run" {
api_id = aws_apigatewayv2_api.war.id
route_key = "POST /reviews"
target = "integrations/${aws_apigatewayv2_integration.collector.id}"
}
Protect the endpoint with IAM authorization or an API key — an unauthenticated endpoint that enumerates your infrastructure weaknesses is itself a HIGH finding.
Step 4: Reading the results
Each run drops a timestamped JSON report into S3. From there you can render it into Markdown for the review document, feed it to QuickSight for a findings dashboard, or diff two runs to prove remediation progress between assessments — which is exactly what consultants spend hours doing by hand.
Lessons learned
- Paginate everything.
list_bucketsis small, butdescribe_db_instancesand friends will silently truncate in real accounts. Use paginators from day one. - Throttle yourself. Fanning out
Describe*calls across regions can hit API rate limits; add exponential backoff. - The report is a security artifact. Encrypt the bucket, block public access, and set a lifecycle policy. Your findings document is a roadmap for an attacker.
- Automate the boring 70%, keep humans for the 30%. Resource data collection is mechanical; interpreting trade-offs against business context is not. This tool made reviews faster — it didn’t replace the review.