← All posts
July 1, 2026 3 min read

Automating AWS Well-Architected Reviews with Lambda, API Gateway and S3

#aws#serverless#well-architected#poc

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 ReadOnlyAccess with a scoped policy listing only the Describe*/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

  1. Paginate everything. list_buckets is small, but describe_db_instances and friends will silently truncate in real accounts. Use paginators from day one.
  2. Throttle yourself. Fanning out Describe* calls across regions can hit API rate limits; add exponential backoff.
  3. 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.
  4. 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.

Written by Rishi Raj — Cloud DevSecOps Engineer. Questions or feedback? Get in touch.