AWS Builder Center
Kiro for AWS Infrastructure: Spec-Driven Terraform and Lambda Development

Kiro for AWS Infrastructure: Spec-Driven Terraform and Lambda Development

Infrastructure code benefits from specifications far more than application code because mistakes can lead to security vulnerabilities or outages. This guide shows how to use AWS Kiro's spec-driven workflow to build a Terraform-provisioned Lambda API, using steering documents, EARS-based requirements, architecture designs, dependency-tracked tasks, and agent hooks to generate traceable, secure infrastructure code instead of relying on ad hoc AI prompts.

AI Engineering

Why infrastructure code needs specs more than app code does

Terraform and Lambda changes are exactly the kind of work where "just prompt an AI and see what comes out" is dangerous — a wrong IAM policy or a missing VPC config doesn't just produce a bug, it produces a security incident or an outage. <cite index="24-1">Kiro is AWS's agentic IDE, built as a ground-up replacement for Amazon Q Developer, and its defining feature is that it requires structured specifications before generating code instead of letting you iterate freely from a raw prompt.</cite>
This post builds a real feature — a Lambda function fronted by API Gateway, provisioned with Terraform — entirely through Kiro's spec-driven workflow: steering docs that encode your AWS conventions, a requirements.md written in EARS notation, a design.md with an architecture diagram, and a tasks.md that Kiro executes with traceability back to the original requirement.

How the pieces fit together

Kiro's spec workflow sits on top of two persistent context sources — steering docs (your team's standing conventions) and, optionally, an MCP server (live AWS account context) — and produces three linked markdown artifacts before touching a single .tf file.
The task-execution loop itself is worth understanding, since it's what makes Kiro's output traceable rather than a single opaque code dump:
Step 1 — Give Kiro project context with steering docs
Before writing any spec, generate steering docs so Kiro understands your AWS conventions instead of inventing its own on every prompt. <cite index="29-1">Steering is read on every prompt, so an inaccuracy here propagates into every future spec</cite> — worth reviewing carefully once, rather than fighting it repeatedly.
Kiro auto-generates three files under .kiro/steering/ by inspecting your repo, but for infrastructure work it's worth hand-editing tech.md to pin down conventions explicitly:
1
2
3
4
5
6
7
8
9
10
<!-- .kiro/steering/tech.md -->
# Technology Stack

- IaC tool: Terraform >= 1.7, AWS provider ~> 5.0
- Lambda runtime: Python 3.12
- State backend: S3 with DynamoDB locking (see infra/backend.tf)
- Naming convention: {env}-{service}-{resource}, all lowercase, hyphenated
- All Lambda roles must use least-privilege inline policies, never AdministratorAccess
- API Gateway: REST APIs only (no HTTP APIs) for this project, for WAF compatibility
- Every Lambda must have a corresponding CloudWatch alarm on Errors and Throttles
You can add a custom steering file for domain-specific rules — for example, security constraints that should apply to every spec touching infrastructure:
1
2
3
4
5
6
<!-- .kiro/steering/security.md -->
# Security Steering

- Never generate an IAM policy with "*" as the Resource unless explicitly justified in design.md
- All S3 buckets must have public access blocked and versioning enabled
- Secrets must be referenced via AWS Secrets Manager ARNs, never hardcoded

Step 2 — Requirements in EARS notation

Instead of prompting "build me a Lambda that processes uploaded files," Kiro expands the request into a requirements.md using EARS (Easy Approach to Requirements Syntax), where <cite index="30-1">each requirement follows the pattern: WHEN [condition/event] THE SYSTEM SHALL [expected behavior]</cite>.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- .kiro/specs/file-processor/requirements.md -->
# Requirements: S3 File Processor

## User Story
As a data engineer, I want uploaded CSV files to be validated and
transformed automatically, so that downstream jobs only see clean data.

## Acceptance Criteria (EARS)

1. WHEN a file is uploaded to the `raw/` prefix of the input S3 bucket
THE SYSTEM SHALL invoke the processing Lambda within 30 seconds.

2. WHEN the uploaded file is not valid CSV
THE SYSTEM SHALL move it to a `rejected/` prefix and publish a
CloudWatch metric `FileValidationFailure`.

3. WHEN the uploaded file is valid CSV
THE SYSTEM SHALL write the transformed output to the `processed/`
prefix of the output bucket.

4. WHEN the Lambda invocation fails after 3 retries
THE SYSTEM SHALL send the event to a dead-letter queue for manual review.

5. WHERE the Lambda requires S3 read/write access
THE SYSTEM SHALL scope IAM permissions to only the two buckets
involved, not to `s3:*`.
You can ask Kiro to run Analyze Requirements at this stage, which checks the draft for logical inconsistencies, ambiguities, and gaps before you approve moving to design — catching, for example, requirement 2 and 4 potentially conflicting on what happens to a file that's both invalid and fails processing.

Step 3 — Design, generated from requirements + steering

design.md is where Kiro documents the technical architecture, including diagrams, translating each requirement into concrete AWS resources consistent with your steering docs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- .kiro/specs/file-processor/design.md -->
# Design: S3 File Processor

## Architecture
- S3 bucket (input) with EventBridge notifications on `raw/*` prefix
- Lambda (Python 3.12) triggered via EventBridge rule
- Lambda IAM role scoped to: s3:GetObject on input bucket/raw/*,
s3:PutObject on input bucket/rejected/* and output bucket/processed/*
- SQS dead-letter queue attached to the Lambda's async invocation config
- CloudWatch alarm on Lambda Errors > 0 over 5 minutes -> SNS topic

## Sequence

1. Object PUT to raw/ triggers EventBridge rule
2. EventBridge invokes Lambda asynchronously
3. Lambda validates CSV structure (headers, row count > 0)
4. On failure: copy to rejected/, emit custom metric, delete from raw/
5. On success: transform, write to processed/, delete from raw/
6. On repeated invocation failure: EventBridge DLQ captures the event

## Traceability
- Req 1, 3 -> EventBridge rule + Lambda handler (Step 2-3, 5)
- Req 2 -> validation branch + custom metric (Step 4)
- Req 4 -> DLQ configuration
- Req 5 -> IAM policy scoping (see security.md steering)
This is also where an MCP server connected to your AWS account earns its keep — instead of Kiro guessing at existing resource names, it can query real IAM roles, VPC IDs, or bucket ARNs and reference them directly in the design, so the generated Terraform doesn't collide with what's already deployed.

Step 4 — Tasks, broken into dependency-ordered waves

tasks.md turns the design into discrete, trackable units. Kiro builds a dependency graph and groups independent tasks into waves it can potentially parallelize.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- .kiro/specs/file-processor/tasks.md -->
# Tasks: S3 File Processor

- [ ] 1. Create S3 buckets (input, output) with versioning + public access block
_Req: 1, 3_
- [ ] 2. Create IAM role and least-privilege policy for the Lambda
_Req: 5_
_Depends on: 1_
- [ ] 3. Write Lambda handler: CSV validation + transform logic
_Req: 2, 3_
- [ ] 4. Wire EventBridge rule (raw/* PUT -> Lambda invoke)
_Req: 1_
_Depends on: 1, 2, 3_
- [ ] 5. Configure DLQ + async invocation config on the Lambda
_Req: 4_
_Depends on: 3_
- [ ] 6. Add CloudWatch alarm on Errors metric -> SNS
_Req: 4_
_Depends on: 3_

Step 5 — The generated Terraform (task 1–2 output)

Kiro executes tasks one at a time, presenting a diff for review before moving on — here's representative output for the bucket and IAM task, consistent with the tech.md and security.md steering constraints above.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# infra/s3.tf
resource "aws_s3_bucket" "input" {
bucket = "prod-fileproc-input"
}

resource "aws_s3_bucket_versioning" "input" {
bucket = aws_s3_bucket.input.id
versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_public_access_block" "input" {
bucket = aws_s3_bucket.input.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

resource "aws_s3_bucket" "output" {
bucket = "prod-fileproc-output"
}
# ... versioning + public access block mirrored for output bucket
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# infra/iam.tf
data "aws_iam_policy_document" "lambda_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}

resource "aws_iam_role" "file_processor" {
name = "prod-fileproc-lambda-role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

data "aws_iam_policy_document" "file_processor_scoped" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.input.arn}/raw/*"]
}
statement {
actions = ["s3:PutObject", "s3:DeleteObject"]
resources = [
"${aws_s3_bucket.input.arn}/rejected/*",
"${aws_s3_bucket.output.arn}/processed/*",
]
}
}

resource "aws_iam_role_policy" "file_processor" {
name = "scoped-s3-access"
role = aws_iam_role.file_processor.id
policy = data.aws_iam_policy_document.file_processor_scoped.json
}
Note there's no s3:* or wildcard resource anywhere — that's the security.md steering doc constraint being enforced, and it's exactly the kind of detail that's easy to lose in freeform prompting but hard to lose when it's a standing rule read on every task.

Step 6 — Lambda handler (task 3 output)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# src/handler.py
import boto3
import csv
import io
import os

s3 = boto3.client("s3")
cloudwatch = boto3.client("cloudwatch")

INPUT_BUCKET = os.environ["INPUT_BUCKET"]
OUTPUT_BUCKET = os.environ["OUTPUT_BUCKET"]

def handler(event, context):
record = event["Records"][0]
key = record["s3"]["object"]["key"]

obj = s3.get_object(Bucket=INPUT_BUCKET, Key=key)
body = obj["Body"].read().decode("utf-8")

if not _is_valid_csv(body):
rejected_key = key.replace("raw/", "rejected/", 1)
s3.put_object(Bucket=INPUT_BUCKET, Key=rejected_key, Body=body)
s3.delete_object(Bucket=INPUT_BUCKET, Key=key)
cloudwatch.put_metric_data(
Namespace="FileProcessor",
MetricData=[{"MetricName": "FileValidationFailure", "Value": 1}],
)
return {"status": "rejected", "key": key}

transformed = _transform(body)
processed_key = key.replace("raw/", "processed/", 1)
s3.put_object(Bucket=OUTPUT_BUCKET, Key=processed_key, Body=transformed)
s3.delete_object(Bucket=INPUT_BUCKET, Key=key)
return {"status": "processed", "key": processed_key}

def _is_valid_csv(body: str) -> bool:
try:
reader = csv.reader(io.StringIO(body))
rows = list(reader)
return len(rows) > 1 and len(rows[0]) > 0
except csv.Error:
return False

def _transform(body: str) -> str:
reader = csv.DictReader(io.StringIO(body))
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=reader.fieldnames)
writer.writeheader()
for row in reader:
writer.writerow({k: v.strip() for k, v in row.items()})
return output.getvalue()
Every branch here traces back to a requirement ID in design.md's traceability section — which is the practical payoff of the spec step: a reviewer (or an auditor) can answer "why does this code exist" without archaeology.

Step 7 — Automate the plan/validate loop with an Agent Hook

Agent hooks execute predefined actions on file system events, so you can require a terraform plan review to run automatically on every save instead of only at commit time.
1
2
3
4
5
6
7
8
9
10
# .kiro/hooks/terraform-plan-on-save.yml
name: Terraform Plan on Save
event: file_saved
file_pattern: "infra/**/*.tf"
instructions: |
Run `terraform validate` and `terraform plan` for the infra/ directory.
Summarize any resource changes, especially IAM policy or public-access
changes, in a comment back to the developer. Flag anything that widens
permissions beyond what design.md specifies.
<cite index="19-1">Hooks are stored at the repository level, so your entire team benefits from this automation immediately upon repository checkout</cite> — a new engineer cloning the repo gets the same plan-on-save safety net without any local setup.

Comparing Kiro's spec-driven flow to freeform prompting for infra work

AspectFreeform promptingKiro spec-driven workflow
Starting pointOne-shot natural language promptrequirements.md reviewed and approved first
IAM scopingDepends on prompt wording; easy to get * by defaultEnforced via steering doc on every task
TraceabilityHard to say why a resource exists after the factEach task/resource links back to a requirement ID
Review granularityOften one large diffDiff per task, reviewable incrementally
Team consistencyDepends on whoever wrote the promptSteering docs apply the same conventions for every contributor
Best fitSmall, throwaway scripts, quick prototypesProduction infrastructure, anything security- or compliance-sensitive

Production considerations

  • Steering docs are only as good as their upkeep. If tech.md drifts from reality (e.g., you migrate to HTTP APIs but never update it), every future spec inherits the stale assumption.
  • MCP context reduces drift, not risk of leakage. Scope the MCP server's AWS credentials to read-only, least-privilege access — it's context for design decisions, not a channel that should be able to mutate your account.
  • Treat tasks.md as a real project artifact. Because Kiro tracks task status and dependencies, it's worth committing spec folders to version control alongside the Terraform itself, not treating them as scratch files.

References

  1. What Is Spec-Driven Development and How to Implement It with Kiro — AWS in Plain English 
  2. Specs — Kiro IDE Docs 
  3. Feature Specs (EARS notation) — Kiro IDE Docs 
  4. Your first project (Agent Hooks, MCP setup) — Kiro IDE Docs 
  5. AWS Kiro Developer Guide 2026 — Developers Digest 
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article