Last reviewed: May 2026
Build the AWS services on the DVA-C02 exam with plain Terraform — one block at a time, each tied back to an exam domain. The same code works on OpenTofu.
By the end of this lab you'll have provisioned, with plain Terraform, the canonical AWS serverless microservice — a DynamoDB table, a Lambda function with a least-privilege IAM role, an HTTP API Gateway in front of it, and CloudWatch Logs + a Lambda-error alarm so you'll know when the service breaks. This is the architecture DVA-C02 tests on every other question.
Every resource is plain Terraform — the same code works without modification on OpenTofu. No variables, no modules, no remote state. Drop the snippets into a single main.tf, run terraform init once, then terraform apply step-by-step.
>= 1.5 or OpenTofu >= 1.6.us-east-1..zip at plan-time via archive_file — no separate build step needed.All resources here are pay-per-use, no idle billing:
The whole stack idles at $0. Destroy when you're done out of habit, not cost panic.
Standard opener: pin aws ~> 5.60, default to us-east-1, tag everything with the project name so Cost Explorer can later report on what this lab spent (~$0 expected, but the habit is the exam-relevant point).
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.4"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Project = "certlabpro-dva-c02"
ManagedBy = "terraform"
}
}
}DynamoDB is the NoSQL store DVA-C02 expects you to reach for in any "single-digit-millisecond latency at scale" scenario. We create one table with on-demand billing (no capacity planning), a partition key, and TTL turned on for automatic record expiration — a recurring DVA-C02 pattern for caching, session storage, and any workload with naturally-expiring data.
No sort key in this lab; we'll only do key-value access. Point-in-time recovery is on by default for any production table the exam expects you to provision; we enable it here too. Both billing_mode = PAY_PER_REQUEST and point_in_time_recovery are high-frequency exam attributes.
resource "aws_dynamodb_table" "items" {
name = "certlabpro-dva-c02-items"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
ttl {
attribute_name = "expires_at"
enabled = true
}
point_in_time_recovery {
enabled = true
}
}The Lambda is where our application logic lives. We give it a least-privilege role: it can write to its CloudWatch log group (the AWSLambdaBasicExecutionRole managed policy) and call exactly four DynamoDB actions (GetItem, PutItem, UpdateItem, Query) against exactly one table — the one we created in Step 2.
The archive_file data source bundles the inline Python handler into a .zip at plan time, so there's no separate build step. The handler is intentionally tiny (echoes the path + table name back as JSON) — DVA-C02 tests Lambda configuration far more than Lambda code, and the exam-relevant attributes are here: runtime, handler, timeout, memory_size, environment variables, and the role.
data "archive_file" "lambda_src" {
type = "zip"
output_path = "${path.module}/build/handler.zip"
source {
filename = "index.py"
content = <<-EOT
import json, os
def handler(event, context):
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({
"table": os.environ["TABLE_NAME"],
"path": event.get("rawPath", "/"),
}),
}
EOT
}
}
resource "aws_iam_role" "lambda" {
name = "certlabpro-dva-c02-lambda"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "lambda_ddb" {
name = "ddb-table-access"
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Query"]
Resource = aws_dynamodb_table.items.arn
}]
})
}
resource "aws_lambda_function" "api" {
function_name = "certlabpro-dva-c02-api"
role = aws_iam_role.lambda.arn
runtime = "python3.12"
handler = "index.handler"
filename = data.archive_file.lambda_src.output_path
source_code_hash = data.archive_file.lambda_src.output_base64sha256
timeout = 10
memory_size = 256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.items.name
}
}
}
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${aws_lambda_function.api.function_name}"
retention_in_days = 7
}API Gateway is what turns our Lambda into something the world can call. DVA-C02 specifically tests HTTP APIs (the cheaper, faster, newer API Gateway flavor) over REST APIs (the older flavor) in most modern questions — HTTP APIs are ~70% cheaper and ~60% lower-latency, and they support the same Lambda integration we need.
Three resources tie this together: the API itself, an integration that points at our Lambda, and a route that maps incoming ANY / requests to that integration. The auto-deploy stage means changes to the API take effect immediately on terraform apply.
The aws_lambda_permission is the bit beginners forget: API Gateway is invoking our Lambda from outside the function's own resource policy, so we need to grant it permission to do so. Without this resource, the API will accept the request and then return a 500 trying to invoke a Lambda it isn't allowed to invoke.
resource "aws_apigatewayv2_api" "main" {
name = "certlabpro-dva-c02"
protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.api.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "root" {
api_id = aws_apigatewayv2_api.main.id
route_key = "ANY /"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.main.id
name = "$default"
auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}
output "api_url" {
value = aws_apigatewayv2_api.main.api_endpoint
}Every Lambda emits an Errors metric to CloudWatch — count of invocations that threw. The DVA-C02 Monitoring, Logging, and Troubleshooting domain (~12% of the exam) is testing whether you instrument this from day one. We create a CloudWatch alarm on the Lambda's error rate and route notifications through SNS to an email address.
After terraform apply, AWS sends a confirmation email to the address in email_endpoint — click Confirm subscription once, and the alarm will then actually reach you when errors spike.
The combination from Steps 2 through 5 is the entire microservice: a stateful store (DynamoDB), stateless compute (Lambda), an HTTPS edge (API Gateway HTTP API), and operational visibility (CloudWatch Logs + alarms). That's the DVA-C02 architecture in five blocks.
resource "aws_sns_topic" "alerts" {
name = "certlabpro-dva-c02-alerts"
}
resource "aws_sns_topic_subscription" "alerts_email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = "you@example.com" # replace with your real email
}
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
alarm_name = "certlabpro-dva-c02-lambda-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 300
statistic = "Sum"
threshold = 5
alarm_description = "Lambda errored more than 5 times in 5 minutes."
alarm_actions = [aws_sns_topic.alerts.arn]
treat_missing_data = "notBreaching"
dimensions = {
FunctionName = aws_lambda_function.api.function_name
}
}terraform destroy tears down everything in this lab cleanly. Two notes:
DVA-C02 covers more developer-facing services than this lab can hold — Step Functions for orchestration, SQS + SNS for async messaging, EventBridge for scheduled jobs, Kinesis Data Streams for real-time pipelines, X-Ray for distributed tracing, Secrets Manager + Parameter Store, Cognito for user auth, AppSync for GraphQL, ECS / EKS / Fargate for containers, CodeCommit + CodeBuild + CodeDeploy + CodePipeline for CI/CD, S3 for static assets, and CloudFront for the edge.
We stick to the single most-tested serverless microservice pattern — Lambda + API Gateway HTTP API + DynamoDB + CloudWatch — because it's the one architecture every DVA-C02 candidate needs to be able to draw and explain from memory. The other patterns build on these primitives (Step Functions composes Lambdas; SQS triggers Lambdas; EventBridge schedules them) and are best learned by adding one piece at a time to this base.
For service-by-service conceptual coverage of the rest, see the Browse, Playbook, and Editorial sections of this cert page.