AWS CDK Tutorial: From Console to Infrastructure as Code
Build and deploy a REST API backed by DynamoDB and Lambda using AWS CDK in Python. Covers setup through teardown with verification at each step.
AWS CDK tutorial: from Console clicks to infrastructure as code
Build a serverless API with DynamoDB, Lambda, and API Gateway in Python
I forgot to set the Lambda timeout. My function died mid-request. I'd spent 45 minutes clicking through four AWS Console pages to wire up a single API, and one missed form field undid all of it. I built cloud environments in this way for six months, in the industry this is called "Click-ops."
By the end of this post, you'll have a working API deployed with a single command, backed by a real database, served over HTTPS, with zero servers to manage. You'll learn infrastructure as code by doing it: deploying each piece yourself, verifying it works, then adding the next. You'll gradually build this cloud architecture:

Cloud Architecture Diagram - Serverless REST API
Why the AWS Console breaks down for infrastructure
Here's what those countless clicks look like: you'd click through the DynamoDB service page to create a table, configure the key schema, pick a billing mode. Then you'd navigate to Lambda, create a function, paste in your Python code, set environment variables, configure the timeout. Then you'd open API Gateway, create a REST API, define resources and methods, link each method to your Lambda function, deploy to a stage. Somewhere in there you'd create an IAM role with the right permissions, and if you forgot a permission, you'd get a cryptic "AccessDeniedException" and start the guessing game as to where you went wrong.
That's fine for a Saturday afternoon experiment. Now picture the Monday morning version.
Scenario 1: Your teammate needs the same API. You send them a Slack message: "Go to DynamoDB, create a table, partition key is 'id' (lowercase), string type, pay-per-request billing. Then go to Lambda..." By the third paragraph they've stopped reading. They create the table with a partition key named "ID" (capital letters), and since DynamoDB keys are case-sensitive, the Lambda function searching for "id" returns empty results. They spend an hour debugging before spotting the difference. Then guess what? Back to the console clicking through the console to fix a single uppercase letter. A productive afternoon.
Scenario 2: You need a staging environment. You click through the same 62 (who's counting? me!) steps again. You're careful this time. You get 58 of the 62 settings right. The four you got wrong don't surface until a week later, when staging behaves differently from production and nobody can figure out why. You will spend that week blaming the network.
Scenario 3: The intern deletes the API Gateway by accident. (This happened to me. I was the intern.) You need to recreate it. You open the console and realize you don't remember half the configuration. The Lambda function's IAM role had a custom policy you wrote three weeks ago. You didn't save it anywhere. You spend the afternoon reverse-engineering your own infrastructure from CloudWatch logs and regret.
Scenario 4: An auditor asks which environments have encryption enabled. You check the console for dev. Then staging. Then production. Each one requires navigating to the DynamoDB table, clicking the "Additional settings" tab, scrolling to the encryption section. Three environments, four services each, and you've burned 20 minutes answering a yes-or-no question.
The console is a fine tool for exploring AWS, a decent tool for one-off experiments, and a terrible tool for anything you plan to keep and repurpose for other projects.
What is infrastructure as code and why should I use it?
What if those 62 clicks were represented in a file? A file you could commit to Git, hand to a teammate, or rerun after a disaster. That's infrastructure as code — you write a file that describes your AWS resources, a tool reads it, and everything gets created for you. The same file produces the same environment every time.
With IaC, each scenario plays out differently:
- Your teammate needs the same API. You send them the repo link. They run
cdk deploy. Identical environment, same casing, same timeout, same permissions. The stack file is the documentation and the implementation in one place. - You need a staging environment. Deploy the same code with a different stack name. All 62 settings right, every time. Environment drift stops existing.
- The intern deletes the API Gateway. (Still me.) Run
cdk deployagain. CDK sees the missing resource and recreates it. The IAM policy is in the stack file, version-controlled, with a Git history showing who wrote it and why. Recovery takes two minutes. - The auditor asks about encryption. Run
grep "encryption" crud_api_stack.py. Every infrastructure change goes through code review.git logshows who changed what, when, and why. - Cost control.
cdk destroytears down everything in the right order. No forgotten tables quietly billing you for months.
CloudFormation vs CDK vs Terraform — which should I use?
| CloudFormation | AWS CDK | Terraform | |
|---|---|---|---|
| You write | YAML or JSON | Python, TypeScript, JavaScript, Java, C#, Go | HCL (HashiCorp Configuration Language) |
| How much it does for you | Low. You specify every detail yourself. | High. Fills in sensible defaults so you write less. | Medium. Reusable modules help, but you still configure a lot. |
| Cloud support | AWS only | AWS only (generates CloudFormation) | Multi-cloud |
| Learning curve | Steep. Verbose templates require careful formatting. | Gentle. Your existing language with guardrails. | Medium. HCL is its own language, but well-documented. |
CloudFormation — YAML/JSON files that describe AWS resources. CDK generates CloudFormation under the hood, so understanding it helps you debug CDK when things go wrong.
AWS CDK — define infrastructure in Python, TypeScript, Java, C#, or Go. You get loops, conditionals, type checking, and autocomplete instead of hand-formatting YAML.
Terraform — its own language (HCL), works across multiple cloud providers. For AWS-only projects, CDK gives tighter integration and less boilerplate.
To see the difference, here's a DynamoDB table in CloudFormation YAML vs Python CDK:
1
2
3
4
5
6
7
8
9
10
11
12
Resources:
ItemsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: items-table
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
BillingMode: PAY_PER_REQUEST1
2
3
4
5
6
7
8
9
10
from aws_cdk import aws_dynamodb as dynamodb
table = dynamodb.Table(self, "ItemsTable",
table_name="items-table",
partition_key=dynamodb.Attribute(
name="id",
type=dynamodb.AttributeType.STRING,
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
)
CDK translates to Cloudformation to Provision DynamoDB Instance
Thirteen lines of YAML became seven lines of Python. CDK filled in the defaults for deletion behavior, encryption, and tags. This tutorial uses Python CDK because Python is readable, CDK handles the wiring between resources for you, and the 62 clicks become one command:
cdk deploy.What you'll build: A Serverless REST API on AWS
You'll build a serverless REST API you can interact with from your terminal — create, read, update, and delete items over HTTPS. The architecture looks like this:
Three AWS services, each doing one job:
- API Gateway receives HTTP requests (GET, POST, PUT, DELETE) and routes them to your Lambda function. It handles HTTPS, throttling, and request validation. It's the front door to your API.
- Lambda runs your Python code without you managing a server. AWS spins up a container, runs your function, and shuts it down. You pay per request, not per hour. Lambda supports Python 3.10 through 3.14 as of this writing; we'll use 3.12.
- DynamoDB stores your data. It's a NoSQL database, which means you don't define columns up front. You give each item a unique key, and DynamoDB handles the rest. It scales from zero to millions of requests without you configuring anything.
CDK wires these three services together. You'll write the Python that defines each resource, and CDK generates the IAM permissions (the rules that say "this Lambda function can read and write to this specific DynamoDB table, and nothing else") automatically.
What this costs: For tutorial traffic levels, close to nothing. Lambda's always-free tier covers 1 million requests/month, forever. DynamoDB's always-free tier covers 25 GB of storage, but the free request allowance only applies to provisioned capacity mode — the on-demand mode we use charges per request from the first request. At tutorial traffic, that's fractions of a penny. API Gateway's free tier depends on when you created your account: pre-July 2025 accounts get 1 million REST API calls/month for 12 months; newer accounts get up to $200 in credits across eligible services. The final section shows you how to tear everything down when you're done.
Prerequisites
You need four things installed before you start. If any of these checks fail, follow the linked instructions and come back.
The commands in this tutorial use Mac/Linux syntax. Run them in your terminal (Terminal on Mac, any Linux shell, or WSL/PowerShell on Windows). On Windows, replace source .venv/bin/activate with .venv\Scripts\activate. The $ at the start of terminal commands is the shell prompt, not something you type.
- An AWS account. The free tier covers this tutorial. Sign up if you haven't. You can find a full video explaining how to do this here: Setting Up AWS Free Tier Account
- Python 3.12 or later. Check with:
1
python3 --version- Node.js (CDK's command-line tool runs on Node). Check with:
1
node --version- The AWS CLI, installed and configured. Install the CLI , then configure it. The quickest way for a personal account is
aws configurewith an access key. If your organization uses IAM Identity Center, useaws configure ssoinstead. You can also find a video that explains step by step how to get the AWS CLI configured in this video . Verify it worked:
1
aws sts get-caller-identityYou should see output like
{ "Account": "123456789012", "Arn": "arn:aws:iam::..." }. If you see your account ID, you're set.Install the CDK command-line tool:
1
2
npm install -g aws-cdk
cdk --versionSet up a Python CDK project
Create a directory and initialize a Python CDK app:
1
2
mkdir crud-api && cd crud-api
cdk init app --language pythonCDK generates a project structure and a virtual environment. Activate it and install dependencies:
1
2
source .venv/bin/activate
pip install -r requirements.txtYour project looks like this:
1
2
3
4
5
6
crud-api/
├── app.py # CDK app entry point
├── crud_api/
│ └── crud_api_stack.py # Your stack definition (where you define AWS resources)
├── requirements.txt
└── cdk.json
CDK Project Structure Summary of Files and Folders
You'll notice
crud-api (with a hyphen) is the directory, but crud_api (with an underscore) is the Python package. CDK converts hyphens to underscores because Python package names can't contain hyphens.app.py is the entry point — it creates a CDK "app" and instantiates your stack. The stack file inside crud_api/ is where you define your AWS resources. That's the file you'll spend most of your time in.You also need a folder for your Lambda function code. Create it now:
1
mkdir lambdaYour project now has two places where you write Python:
crud_api/ for infrastructure, and lambda/ for application code. CDK deploys both.CDK needs a one-time bootstrap step to set up deployment resources in your AWS account (an S3 bucket, ECR repository, and IAM roles that CDK uses to stage and deploy assets). Run it once per account/region combination:
1
cdk bootstrap
CDK Bootstrap works with S3 to Package Lambda Zip files
Deploy a DynamoDB table with CDK
Open
crud_api/crud_api_stack.py and replace its contents. You'll build the stack in stages, starting with the database and deploying after each step.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
from aws_cdk import (
Stack,
RemovalPolicy, # Controls what happens to resources when you delete the stack
CfnOutput, # Prints useful values (like URLs) after deployment
aws_dynamodb as dynamodb,
)
from constructs import Construct # The base class for all CDK resources
class CrudApiStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
# Pass any extra settings (like AWS region) to the parent Stack class
super().__init__(scope, id, **kwargs)
# --- DATABASE ---
# DynamoDB is a NoSQL database. Each item needs a unique "partition key."
# Think of it like a Python dictionary: the partition key is the dict key,
# and the item is the dict value.
items_table = dynamodb.Table(
self,
"ItemsTable",
partition_key=dynamodb.Attribute(
name="id", # Each item gets a unique "id" field
type=dynamodb.AttributeType.STRING, # The id will be a string (like a UUID)
),
# PAY_PER_REQUEST means you pay per read/write, not for reserved capacity.
# At tutorial traffic levels, this costs fractions of a penny.
# For production with unpredictable traffic, it's also the safer choice.
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
# DESTROY means "delete this table when I run cdk destroy."
# The default is RETAIN, which keeps the table (and the bill) even after
# you delete the stack. For a learning project, DESTROY is what you want.
removal_policy=RemovalPolicy.DESTROY,
)
# --- OUTPUTS ---
CfnOutput(
self,
"TableName",
value=items_table.table_name,
description="DynamoDB table name",
)partition_key is the only required configuration for a DynamoDB table. Every item you store needs a unique value for this field. You'll use UUIDs later when you write the Lambda function.billing_mode — PAY_PER_REQUEST charges per operation (pennies for thousands of requests). PROVISIONED reserves fixed capacity, cheaper at high volume but costs money when idle. For a tutorial, on-demand is the right call.removal_policy — the default RETAIN keeps the resource alive after the stack is gone. That's a safety feature for production databases, but for a tutorial it means you'd keep paying for a table you forgot about. DESTROY deletes it with the stack.
DynamoDB Config Parameters
Every CDK construct follows the same pattern: first argument is
self (the stack), second is a string ID, then resource-specific configuration as keyword arguments. You'll see this pattern in every construct you create.
CDK Construct Pattern
Deploy and verify
You will use some built-in CDK commands to view the difference between your cloud environment and what the CDK code you're deploying will generate. You'll be seeing various CDK commands in this tutorial. This diagram summarizes the CDK commands you'll get familiar with:

CDK Commands Summary
Preview what CDK will create, then deploy:
1
2
cdk diff
cdk deploycdk diff compares your code against what's currently deployed and shows you exactly what will change. It's like git diff for infrastructure. Get in the habit of running it before every deploy.You'll see a summary of the IAM policies CDK plans to create. Type
y to confirm. After a minute or so, you should see:1
2
3
4
✅ CrudApiStack
Outputs:
CrudApiStack.TableName = CrudApiStack-ItemsTable5AAC2C46-EYI73VHZNHC6Your DynamoDB table is live. You can verify it exists:
1
aws dynamodb describe-table --table-name $(aws cloudformation describe-stacks --stack-name CrudApiStack --query "Stacks[0].Outputs[?OutputKey=='TableName'].OutputValue" --output text) --query "Table.TableStatus"Note: This command is doing two things. The $(aws cloudformation describe-stacks ...) part asks CloudFormation "what's the actual table name for my stack?" — because CDK appends a unique suffix to resource names (that ItemsTable5AAC2C46-EYI73VHZNHC6 you saw in the deploy output). The outer command (aws dynamodb describe-table ...) takes that name and asks DynamoDB for the table's status (if its active or not).
You should see
"ACTIVE". Here's what you've built so far — a single DynamoDB table:What are CDK construct levels?
That
Table class you just used is an L2 construct — CDK's sweet spot. CDK has three levels:L1 constructs (low-level) map one-to-one to CloudFormation resources. They start with
Cfn — like dynamodb.CfnTable. You set every property yourself. Useful when CDK's higher-level constructs don't expose a setting you need.L2 constructs (high-level) are what you'll use in this tutorial. Classes like
dynamodb.Table and _lambda.Function — no Cfn prefix. They fill in sensible defaults and give you helper methods like grant_read_write_data().L3 constructs (patterns) wire multiple resources together in a single call. For example,
apigateway.LambdaRestApi creates an API Gateway, connects it to a Lambda function, and sets up permissions in one line. Convenient when the defaults match what you need, but less flexible when you want custom routing (which is why we use RestApi directly in this tutorial).If you see a
Cfn-prefixed class in someone else's code, they dropped down to L1 for fine-grained control over a specific property.Write the Lambda function (Python CRUD handler)
Before you define the Lambda resource in CDK, you need the Python code it will run. Create
lambda/index.py: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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import json
import os
import uuid
import boto3 # AWS SDK for Python, pre-installed in Lambda
# Connect to DynamoDB using the table name passed in as an environment variable.
# CDK sets this variable when it creates the Lambda function (you'll see how in Part 4).
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def handler(event, context):
"""
Main entry point for the Lambda function.
API Gateway sends an 'event' dict containing the HTTP method, path,
body, and any path parameters. This function routes to the right
operation based on the HTTP method.
"""
http_method = event["httpMethod"] # "GET", "POST", "PUT", or "DELETE"
path = event.get("resource", "") # "/items" or "/items/{id}"
try:
# Route to the correct handler based on HTTP method and path
if http_method == "GET" and path == "/items":
return get_all_items()
elif http_method == "GET" and path == "/items/{id}":
item_id = event["pathParameters"]["id"]
return get_item(item_id)
elif http_method == "POST" and path == "/items":
body = json.loads(event["body"])
return create_item(body)
elif http_method == "PUT" and path == "/items/{id}":
item_id = event["pathParameters"]["id"]
body = json.loads(event["body"])
return update_item(item_id, body)
elif http_method == "DELETE" and path == "/items/{id}":
item_id = event["pathParameters"]["id"]
return delete_item(item_id)
# If none of the routes matched, return 400
return api_response(400, {"error": "Unsupported route"})
except Exception as e:
# Catch-all for unexpected errors. In production you'd log more detail.
return api_response(500, {"error": str(e)})
def get_all_items():
"""Scan the table and return all items."""
result = table.scan()
return api_response(200, result["Items"])
def get_item(item_id):
"""Fetch a single item by its id."""
result = table.get_item(Key={"id": item_id})
if "Item" not in result:
return api_response(404, {"error": f"Item {item_id} not found"})
return api_response(200, result["Item"])
def create_item(body):
"""Create a new item with a generated UUID."""
item = {
"id": str(uuid.uuid4()), # Generate a unique id for the new item
"name": body.get("name", ""),
"description": body.get("description", ""),
}
table.put_item(Item=item)
return api_response(201, item)
def update_item(item_id, body):
"""Update an existing item's name and description."""
result = table.update_item(
Key={"id": item_id},
# UpdateExpression is DynamoDB's syntax for partial updates.
# SET means "change these fields." The :n and :d are placeholders
# that map to ExpressionAttributeValues below.
UpdateExpression="SET #n = :n, description = :d",
# "name" is a reserved word in DynamoDB, so we use #n as an alias
ExpressionAttributeNames={"#n": "name"},
ExpressionAttributeValues={
":n": body.get("name", ""),
":d": body.get("description", ""),
},
ReturnValues="ALL_NEW", # Return the full item after the update
)
return api_response(200, result["Attributes"])
def delete_item(item_id):
"""Delete an item by its id."""
table.delete_item(Key={"id": item_id})
return api_response(200, {"message": f"Deleted {item_id}"})
def api_response(status_code, body):
"""
Build the response dict that API Gateway expects.
API Gateway requires this exact format: statusCode, headers, and body.
The body must be a JSON string, not a dict.
"""
return {
"statusCode": status_code,
"headers": {
"Content-Type": "application/json",
# Allow requests from any origin (fine for a tutorial,
# restrict this in production)
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps(body, default=str), # default=str handles Decimal types from DynamoDB
}The routing logic reads the HTTP method and path from the API Gateway event, then calls the right function. Each function talks to DynamoDB using
boto3, the AWS SDK for Python that comes pre-installed in every Lambda runtime.A few things worth noting:
os.environ["TABLE_NAME"]— how the Lambda knows which DynamoDB table to use. You'll set this environment variable in CDK in the next step.default=strinjson.dumps— handles a DynamoDB quirk: it returns numbers as PythonDecimalobjects, whichjson.dumpscan't serialize by default.ExpressionAttributeNamesin the update function —nameis a reserved word in DynamoDB, so you alias it as#n. This trips up everyone the first time.
You can't test this function locally yet — it needs the DynamoDB table and the
TABLE_NAME environment variable that CDK will set. You'll test it after the full stack is deployed.
Index.py file Overview
Deploy a Lambda function with CDK
Go back to
crud_api/crud_api_stack.py and add the Lambda function. Here's the complete updated file: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
52
53
54
55
56
from aws_cdk import (
Stack,
RemovalPolicy,
CfnOutput,
Duration, # For setting timeouts
aws_dynamodb as dynamodb,
aws_lambda as _lambda, # "lambda" is a reserved word in Python, so CDK uses "_lambda"
)
from constructs import Construct
class CrudApiStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
# --- DATABASE ---
items_table = dynamodb.Table(
self,
"ItemsTable",
partition_key=dynamodb.Attribute(
name="id",
type=dynamodb.AttributeType.STRING,
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
)
# --- LAMBDA FUNCTION ---
# Lambda runs your code without you managing a server.
# "index.handler" means: in index.py, call the function named handler.
# CDK zips the "lambda" folder and uploads it to AWS during deployment.
api_handler = _lambda.Function(
self,
"ApiHandler",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="index.handler",
code=_lambda.Code.from_asset("lambda"),
environment={
"TABLE_NAME": items_table.table_name,
},
timeout=Duration.seconds(10),
memory_size=128,
)
# --- PERMISSIONS ---
# Generates an IAM policy allowing the Lambda to read/write this table.
# Without this, every database call returns "AccessDeniedException."
items_table.grant_read_write_data(api_handler)
# --- OUTPUTS ---
CfnOutput(
self,
"TableName",
value=items_table.table_name,
description="DynamoDB table name",
)The
_lambda import name deserves an explanation. lambda is a reserved keyword in Python (it creates anonymous functions), so the CDK team named the module aws_lambda. The convention is to import it as _lambda. You'll see this in every Python CDK project.code=_lambda.Code.from_asset("lambda") tells CDK to zip the contents of your lambda/ folder and upload it to S3 during deployment. Lambda downloads the zip and runs your code.items_table.grant_read_write_data(api_handler) is where CDK earns its keep. In raw CloudFormation, you'd write 15-20 lines of IAM policy JSON to grant these permissions. CDK generates that policy from a single method call, scoped to the exact table and the exact Lambda function.Note: The crud_api_stack.py file is for defining the infrastructure, and the lambda/index.py file is code to define the Lambda function's code that will run when the Lambda runs.

Difference Between crud_api_stack.py File From index.py File
Deploy and verify
1
2
cdk diff
cdk deployAfter deployment, verify the Lambda function exists:
1
aws lambda list-functions --query "Functions[?starts_with(FunctionName, 'CrudApiStack')].FunctionName" --output textConnect API Gateway to Lambda with CDK
API Gateway is the front door — it receives HTTP requests from the internet and forwards them to your Lambda function. Here's the complete final
crud_api/crud_api_stack.py: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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
from aws_cdk import (
Stack,
RemovalPolicy,
CfnOutput,
Duration,
aws_dynamodb as dynamodb,
aws_lambda as _lambda,
aws_apigateway as apigw, # API Gateway constructs
)
from constructs import Construct
class CrudApiStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
# --- DATABASE ---
items_table = dynamodb.Table(
self,
"ItemsTable",
partition_key=dynamodb.Attribute(
name="id",
type=dynamodb.AttributeType.STRING,
),
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
removal_policy=RemovalPolicy.DESTROY,
)
# --- LAMBDA FUNCTION ---
api_handler = _lambda.Function(
self,
"ApiHandler",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="index.handler",
code=_lambda.Code.from_asset("lambda"),
environment={
"TABLE_NAME": items_table.table_name,
},
timeout=Duration.seconds(10),
memory_size=128,
)
# Grant the Lambda function permission to read/write the table
items_table.grant_read_write_data(api_handler)
# --- API GATEWAY ---
api = apigw.RestApi(
self,
"ItemsApi",
rest_api_name="Items Service",
description="CRUD API for items, built with CDK",
)
# LambdaIntegration tells API Gateway: "forward requests to this function"
items_integration = apigw.LambdaIntegration(api_handler)
# --- ROUTES ---
# Like defining routes in Flask or Express
items_resource = api.root.add_resource("items")
items_resource.add_method("GET", items_integration) # List all items
items_resource.add_method("POST", items_integration) # Create item
single_item = items_resource.add_resource("{id}") # /items/{id}
single_item.add_method("GET", items_integration) # Get one item
single_item.add_method("PUT", items_integration) # Update item
single_item.add_method("DELETE", items_integration) # Delete item
# --- OUTPUTS ---
CfnOutput(
self,
"ApiEndpoint",
value=api.url,
description="API Gateway endpoint URL",
)
CfnOutput(
self,
"TableName",
value=items_table.table_name,
description="DynamoDB table name",
)The route structure mirrors what you'd write in any web framework:
| Route | Method | Action |
|---|---|---|
/items | GET | List all items |
/items | POST | Create a new item |
/items/{id} | GET | Get one item |
/items/{id} | PUT | Update one item |
/items/{id} | DELETE | Delete one item |
api.root.add_resource("items") creates the /items path. .add_resource("{id}") nests under it to create /items/{id}. API Gateway extracts the {id} value and passes it to your Lambda function in event["pathParameters"]["id"].LambdaIntegration connects API Gateway to Lambda. CDK also generates the IAM permission that allows API Gateway to invoke your function — in CloudFormation, you'd need a separate AWS::Lambda::Permission resource for this.Deploy and verify
1
2
cdk diff
cdk deployThe diff should show the new API Gateway resources, routes, and the Lambda permission being added.
After deployment, you'll see the API URL in the output:
1
2
3
4
5
✅ CrudApiStack
Outputs:
CrudApiStack.ApiEndpoint = https://abc123xyz.execute-api.us-east-1.amazonaws.com/prod/
CrudApiStack.TableName = CrudApiStack-ItemsTable5AAC2C46-EYI73VHZNHC6Test the deployed API with curl
Your API is live. Copy the API URL from the deployment output and test it with
curl.Create an item:
1
curl -X POST https://YOUR_API_URL/items -H "Content-Type: application/json" -d '{"name": "Buy oat milk", "description": "The fancy barista edition"}'You should get back a response with a generated
id:1
2
3
4
5
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Buy oat milk",
"description": "The fancy barista edition"
}List all items:
1
curl https://YOUR_API_URL/itemsGet a single item (use the
id from the create response):1
curl https://YOUR_API_URL/items/a1b2c3d4-e5f6-7890-abcd-ef1234567890Update an item:
1
curl -X PUT https://YOUR_API_URL/items/a1b2c3d4-e5f6-7890-abcd-ef1234567890 -H "Content-Type: application/json" -d '{"name": "Buy oat milk", "description": "Done! Got the barista edition."}'Delete an item:
1
curl -X DELETE https://YOUR_API_URL/items/a1b2c3d4-e5f6-7890-abcd-ef1234567890If any of these return an error, check the Lambda logs. Find the function name with
aws lambda list-functions, then tail the logs:1
aws logs tail /aws/lambda/CrudApiStack-ApiHandler5E7490E8-xxxxxxxxxxxx --followThe
--follow flag streams new log entries as they arrive, like tail -f.How much CloudFormation did CDK generate?
Run
cdk synth | wc -l to see what CDK generated under the hood. For this stack, the output is about 200 lines of CloudFormation YAML. You wrote about 50 lines of Python. CDK handled the IAM roles, the Lambda permission for API Gateway, the deployment stage configuration, and a dozen other details you'd otherwise copy-paste from Stack Overflow.You can also use
cdk synth as a dry run — if your Python has a missing import or misconfigured property, synthesis fails with a traceable error instead of failing two minutes into a deployment.How do I clean up so AWS doesn't charge me?
Delete the stack when you're done:
1
cdk destroyCDK deletes everything it created in the correct order, handling dependencies for you. Type
y to confirm. The DynamoDB table, Lambda function, API Gateway, and all IAM roles disappear.After the destroy completes, verify in the CloudFormation console that the stack is gone.
Common CDK deployment errors and how to fix them
"This stack uses assets, so the toolkit stack must be deployed" — You forgot to bootstrap. Run
cdk bootstrap once per account/region."User: arn:aws:iam::... is not authorized" — Your AWS CLI user lacks permissions. For a personal learning account, you can attach
AdministratorAccess. Never do this in a shared or production account — scope permissions down to what CDK requires ."Runtime.ImportModuleError: Unable to import module 'index'" — Your Lambda code isn't where CDK expects it. Check that
lambda/ contains a file named exactly index.py. The handler="index.handler" line means "find index.py, call the handler function." Run ls lambda/ to verify."AccessDeniedException: ... is not authorized to perform: dynamodb:PutItem" — Your Lambda function doesn't have permission to access DynamoDB. Check that you called
items_table.grant_read_write_data(api_handler) in your stack.You built a serverless API. Keep going.
You started with an empty terminal and ended with a REST API backed by a database, running on AWS, defined in Python, deployed with a single command.
Three projects that build on these skills:
- Add authentication with Amazon Cognito. Protect your API endpoints so only logged-in users can create, update, or delete items.
- Add multiple environments by deploying separate stacks for dev, staging, and production from the same CDK code.
- Add monitoring with CloudWatch alarms. Get notified when your Lambda function errors spike or your DynamoDB table throttles.
My first AWS project involved clicking through the console for two hours, forgetting half the settings, and spending the next day fixing what I missed. You just did it in one command.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article