Deploy a Clinical Note Agent to AWS
Containerize and deploy an AI clinical note agent to AWS Lambda with Terraform.
Introduction
30 Second Summary
Building an AI agent on your laptop is one thing. Making it available as a real API that a hospital system can call at any hour is another challenge entirely.
In this project, you will containerize your Clinical Note Intelligence Agent into a Docker image, deploy it to AWS Lambda, and define all your cloud infrastructure as code using Terraform.
What You'll Build
A fully deployed, production-grade API for your clinical note processing agent, fronted by API Gateway with API key authentication and rate limiting, all managed as Terraform configuration files.
By the end of this project, you'll have:
- A Docker container packaging your agent for serverless deployment on AWS Lambda.
- A complete Terraform codebase that can recreate your entire cloud infrastructure from scratch with a single command.
- A live, authenticated API endpoint that processes clinical notes and returns structured FHIR R4 output with ICD-10 codes.
- Secret Mission: A GitHub Actions CI/CD pipeline that automatically builds and deploys your agent on every push to main.
Are there any prerequisites?
This project builds directly on your Clinical Note Intelligence Agent from Project 1 (using the OpenAI Agents SDK). You need that working agent code before starting here.
You also need a working Docker installation, the Terraform CLI, the AWS CLI configured with credentials, and an AWS account. Step 1 walks you through verifying each of these.
Set Up Your Project Structure
Your Clinical Note Intelligence Agent works locally, but it only runs on your machine. To serve it as a production API, you need to package it into a container, push it to the cloud, and wire up the networking and authentication layers around it.
Before writing any infrastructure code, you need to confirm that your tools are ready and create the project scaffolding. This step verifies Docker, Terraform, and the AWS CLI, then sets up the directory structure for the deployment project.
In this step, get ready to:
- Verify Docker, Terraform, and AWS CLI are working with the correct versions.
- Create the project directory with src/ and terraform/ subdirectories.
- Create requirements.txt with pinned dependencies for the Lambda container.
Verify your tools and configure AWS
This project requires specific minimum versions of Docker and Terraform. Let's confirm each tool is installed and working before building anything.
- Press the Windows key (Windows) or Cmd+Space (macOS) to open your search bar.
- Type PowerShell (Windows) or Terminal (macOS) and press Enter to open it.
- Check your Docker version by running this command:
docker --version
The Lambda base image used in this project requires Docker version 20.10.10 or later.
✔️ I see version 20.10.10 or higher
Docker is ready to build Lambda container images.
ⓧ I see an older version
You need to update Docker Desktop to at least version 20.10.10.
- Open Docker Desktop.
- Click the gear icon to open Settings.
- Select Software updates and click Check for updates.
- Follow the prompts to download and install the latest version.
- After the update completes, run docker --version again to confirm.
ⓧ Command not found
Docker is not installed on your machine.
- Visit the Docker Desktop installation guide and download the installer for your operating system.
- Run the installer and follow the prompts.
- After installation, restart your terminal and run docker --version to confirm version 20.10.10 or higher.
- Check your Terraform version by running:
terraform --version
This project requires Terraform 1.13.0 or higher.
✔️ I see version 1.13.0 or higher
Terraform is ready to go.
ⓧ I see an older version
You need to upgrade Terraform to at least 1.13.0.
- Visit the Terraform install page and download the latest binary for your platform.
- Replace your existing terraform binary with the new one.
- Run terraform --version again to confirm version 1.13.0 or higher.
ⓧ Command not found
Terraform is not installed on your machine.
- Visit the Terraform install page and download the binary for your platform (Windows AMD64 for most Windows machines).
- Extract the binary and add it to your system PATH.
- Restart your terminal and run terraform --version to confirm it shows 1.13.0 or higher.
- Confirm your AWS credentials are working by running:
aws sts get-caller-identity
You should see a JSON response with your Account ID, User ARN, and UserId.
✔️ I see my account details
Your AWS credentials are configured correctly. Note down your Account ID from the output. You will need it later when pushing your Docker image to ECR.
ⓧ I see an error
Your AWS CLI is not configured with valid credentials.
- Run aws configure and enter your Access Key ID, Secret Access Key, default region (us-east-1), and output format (json).
- If you do not have an Access Key, create one in the IAM console under Security credentials.
- Run aws sts get-caller-identity again to confirm it returns your account details.
For the full setup walkthrough, see the AWS CLI configuration guide.
What does aws sts get-caller-identity do?
This command asks AWS "who am I?" using your configured credentials. If it returns a valid response, your access keys and region are set up correctly for all the AWS operations in this project.
Create the project directory structure
The deployment project lives in its own folder, separate from your original Project 1 code. It contains the Lambda handler, Dockerfile, Terraform configs, and a requirements file.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir clinical-note-agent-deploy
cd clinical-note-agent-deploy
- Create the src/ and terraform/ subdirectories by running:
mkdir src
mkdir terraform
- Confirm the directories exist by running:
ls
You should see src and terraform listed in the output.
- Copy your agent pipeline code from Project 1 into the src/ folder.
What files do I copy from Project 1?
The Lambda handler (which you will write in Step 2) imports a function called process_clinical_note from a module called agent_pipeline.
Copy your agent pipeline file(s) from Project 1 into src/ and make sure the file is named agent_pipeline.py with a function called process_clinical_note that accepts a clinical note string and returns the structured extraction result.
Not sure which files to copy?
If your Project 1 code is split across multiple files, copy all of them into src/. The key requirement is that from agent_pipeline import process_clinical_note works when run from inside the src/ directory.
help me figure out which Project 1 files to copy into src/
Create the requirements file
The requirements.txt file tells Docker which Python packages to install inside the Lambda container image.
- Create a file called requirements.txt in the root of your clinical-note-agent-deploy folder (not inside src/ or terraform/).
- Paste the following content into requirements.txt:
openai-agents>=0.17.7
pydantic>=2.0.0
What do these dependencies do?
openai-agents is the OpenAI Agents SDK that powers your clinical note pipeline from Project 1. pydantic handles the structured data models for FHIR output and agent tool definitions.
If your Project 1 code uses additional packages, add them here too. Every package your agent imports must be listed in this file.
- Save requirements.txt.
✔️ Awesome, I've got everything!
Great. Make sure requirements.txt is saved at the project root alongside the src/ and terraform/ folders.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt file:
openai-agents>=0.17.7
pydantic>=2.0.0
Your project scaffolding is in place and all three tools are confirmed working. Next up, you will write the Lambda handler and Dockerfile to containerize your agent for deployment.
Dockerize the Agent
Your project structure is ready with agent code and dependencies in place. Now you need a way to run your agent on AWS Lambda.
Lambda supports running container images directly, which means you can package your entire agent pipeline into a Docker image and deploy it without rewriting anything. In this step, you will write a handler that adapts your agent for Lambda, package everything into a Docker image, and test it locally before pushing to the cloud.
In this step, get ready to:
- Write a Lambda handler wrapper with structured JSON logging.
- Write a Dockerfile to package your agent for Lambda.
- Test the container locally using the Lambda Runtime Interface Emulator.
Write the Lambda handler wrapper
Lambda does not call your agent code directly. It sends an event object to a handler function, and your handler must parse the input, run your pipeline, and return a properly structured response.
- Create a new file called handler.py inside your src/ directory.
- Add the logging setup by pasting this code at the top of src/handler.py:
import json
import logging
import os
import sys
# Configure structured JSON logging for CloudWatch
logger = logging.getLogger()
logger.setLevel(logging.INFO)
json_handler = logging.StreamHandler(sys.stdout)
json_handler.setFormatter(logging.Formatter(json.dumps({
"timestamp": "%(asctime)s",
"level": "%(levelname)s",
"message": "%(message)s",
"module": "%(module)s"
})))
logger.addHandler(json_handler)
What does this code do?
This sets up structured JSON logging so that every log message your handler emits appears as a parseable JSON object in CloudWatch.
Instead of plain text lines, each log entry includes a timestamp, severity level, the message, and the source module. This makes it much easier to search and filter logs in production.
- Add the handler function below the logging setup in src/handler.py:
def handler(event, context):
logger.info("Received clinical note processing request")
try:
# Parse the clinical note from the API Gateway event body
body = json.loads(event.get("body", "{}"))
clinical_note = body.get("clinical_note", "")
if not clinical_note:
logger.warning("Empty clinical note received")
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "clinical_note field is required"})
}
logger.info(f"Processing note of length {len(clinical_note)}")
# Import and run agent pipeline from Project 1
from agent_pipeline import process_clinical_note
result = process_clinical_note(clinical_note)
logger.info("Successfully processed clinical note")
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(result, default=str)
}
except Exception as e:
logger.error(f"Error processing clinical note: {str(e)}")
return {
"statusCode": 500,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Internal processing error", "detail": str(e)})
}
What does this handler do?
- event.get("body", "{}") extracts the JSON body from the API Gateway proxy event.
- The handler validates that a clinical_note field exists in the request and returns a 400 error if it is missing.
- process_clinical_note() is your existing agent pipeline from Project 1, which runs triage, extraction, and coding.
- The response follows API Gateway's proxy integration format with statusCode, headers, and a JSON-serialized body.
- Save src/handler.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/handler.py before moving on.
ⓧ I'd like to double check the full code
import json
import logging
import os
import sys
logger = logging.getLogger()
logger.setLevel(logging.INFO)
json_handler = logging.StreamHandler(sys.stdout)
json_handler.setFormatter(logging.Formatter(json.dumps({
"timestamp": "%(asctime)s",
"level": "%(levelname)s",
"message": "%(message)s",
"module": "%(module)s"
})))
logger.addHandler(json_handler)
def handler(event, context):
logger.info("Received clinical note processing request")
try:
body = json.loads(event.get("body", "{}"))
clinical_note = body.get("clinical_note", "")
if not clinical_note:
logger.warning("Empty clinical note received")
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "clinical_note field is required"})
}
logger.info(f"Processing note of length {len(clinical_note)}")
# Import and run agent pipeline from Project 1
from agent_pipeline import process_clinical_note
result = process_clinical_note(clinical_note)
logger.info("Successfully processed clinical note")
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(result, default=str)
}
except Exception as e:
logger.error(f"Error processing clinical note: {str(e)}")
return {
"statusCode": 500,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "Internal processing error", "detail": str(e)})
}
Write the Dockerfile
AWS provides official base images for Lambda that include the runtime interface emulator for local testing. You will use the Python 3.12 base image built on Amazon Linux 2023.
- Create a file called Dockerfile in the root of your project directory (the same level as requirements.txt).
- Paste the following into your Dockerfile:
# Use AWS Lambda Python 3.12 base image (includes runtime interface emulator)
FROM public.ecr.aws/lambda/python:3.12
# Copy and install dependencies
COPY requirements.txt ${LAMBDA_TASK_ROOT}
RUN pip install -r requirements.txt
# Copy application source code
COPY src/ ${LAMBDA_TASK_ROOT}
# Set the handler function
CMD ["handler.handler"]
What does this Dockerfile do?
- public.ecr.aws/lambda/python:3.12 is the official AWS Lambda base image for Python 3.12, pre-loaded with the runtime interface client and emulator.
- LAMBDA_TASK_ROOT is an environment variable set by the base image that points to where Lambda expects your code to live.
- CMD ["handler.handler"] tells Lambda to call the handler function inside the handler module (handler.py).
- Save the Dockerfile.
- Build the Docker image by running this command from your project root:
docker buildx build --platform linux/amd64 --provenance=false -t clinical-note-agent:latest .
Why linux/amd64?
AWS Lambda runs on x86_64 (amd64) architecture. Even if your machine uses a different architecture (like ARM on Apple Silicon), this flag ensures the image is built for the correct target platform.
The --provenance=false flag prevents Docker from attaching build attestation metadata that can interfere with Lambda's image resolution.
- Wait for the build to complete. You should see output ending with a line that confirms the image was tagged as clinical-note-agent:latest.
Build failing?
- Make sure Docker Desktop is running. The Docker daemon must be active for builds to work.
- Check that your Dockerfile has no file extension. It should be named exactly Dockerfile with no .txt or other suffix.
- Confirm you are running the command from the same directory that contains Dockerfile and requirements.txt.
Help me debug my Docker build error.
✔️ Awesome, I've got everything!
Great. Make sure you saved the Dockerfile and that the build completed successfully.
ⓧ I'd like to double check the full code
FROM public.ecr.aws/lambda/python:3.12
COPY requirements.txt ${LAMBDA_TASK_ROOT}
RUN pip install -r requirements.txt
COPY src/ ${LAMBDA_TASK_ROOT}
CMD ["handler.handler"]
Test locally with the Lambda Runtime Interface Emulator
The AWS Lambda base image includes a built-in Runtime Interface Emulator (RIE) that lets you invoke your function locally before deploying. This saves you from deploying untested code to the cloud.
- Start the container by running this command:
docker run --platform linux/amd64 -p 9000:8080 clinical-note-agent:latest
What does this command do?
This runs your container image and maps port 9000 on your machine to port 8080 inside the container (where the RIE listens). It creates a local endpoint at http://localhost:9000/2015-03-31/functions/function/invocations that behaves like the real Lambda invoke API.
- Open a new terminal window (keep the container running in the first one).
- Send a test request with a sample clinical note by running this command:
Invoke-WebRequest -Uri "http://localhost:9000/2015-03-31/functions/function/invocations" -Method Post -Body '{"body": "{\"clinical_note\": \"Patient presents with persistent cough for 2 weeks and low-grade fever. History of asthma.\"}"}' -ContentType "application/json"
What is this request doing?
The RIE expects a full Lambda event object, not a raw HTTP request. The outermost JSON is the Lambda event, and the body field inside it is the string-encoded JSON that API Gateway would normally pass through.
Your handler will parse this nested structure: extracting body from the event, then JSON-decoding it to get clinical_note.
- Verify the response. You should see a JSON response containing a statusCode of 200 and a body field with structured clinical extraction output.
- When you are done testing, press Ctrl+C in the first terminal to stop the container.
Not getting a response?
- Make sure the container is still running in the first terminal. If it exited with an error, check the output for import errors or missing dependencies.
- Confirm you are posting to port 9000 (not 8080). Port 9000 is the host-side mapping.
- If you see a 500 error with a detail about agent_pipeline, your agent code from Project 1 may not be in the src/ directory. Confirm all your Project 1 source files are there.
Help me debug my local Lambda container test.
Your agent is now containerized and verified locally. Next up, you will write Terraform configuration to provision all the AWS infrastructure your container needs to run in production.
Write Terraform Core Infrastructure
Your agent is containerized and tested locally. Now it is time to define the AWS infrastructure that will host it in the cloud.
Instead of clicking around the AWS console to create resources manually, you will write Terraform configuration files. These files declare exactly what infrastructure you need. Terraform reads them and creates everything for you with a single command.
In this step, get ready to:
- Write the core Terraform configuration with provider, ECR, and CloudWatch resources.
- Define the IAM role and Lambda function that will run your agent.
- Create variables and outputs files to keep your configuration flexible.
Write terraform/main.tf with provider and foundational resources
Every Terraform project starts with a provider configuration that tells Terraform which cloud platform to talk to. You will also define the container registry and log groups that your Lambda function depends on.
- Create a new file called main.tf inside the terraform/ directory, then paste the following code:
terraform {
required_version = ">= 1.13.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# ECR Repository
resource "aws_ecr_repository" "this" {
name = var.project_name
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
# CloudWatch Log Group for Lambda
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${var.project_name}"
retention_in_days = 14
}
# CloudWatch Log Group for API Gateway
resource "aws_cloudwatch_log_group" "api_gw" {
name = "/aws/apigateway/${var.project_name}"
retention_in_days = 14
}
What does this code do?
The terraform block pins the minimum Terraform CLI version to 1.13.0 and locks the AWS provider to the 6.x major version. This prevents breaking changes from sneaking in.
The aws_ecr_repository resource creates an Amazon ECR registry to store your Docker image. Setting image_tag_mutability = "MUTABLE" allows you to overwrite the latest tag on each push.
The two CloudWatch log groups give Lambda and API Gateway dedicated places to write logs, with a 14-day retention period to avoid unbounded storage costs.
- Save terraform/main.tf.
Seeing a syntax highlighting error?
If your editor shows errors on .tf files, it may not have HCL support installed. Look for a Terraform or HCL extension in your editor's marketplace. The file is still valid regardless of editor highlighting.
Help me fix Terraform syntax highlighting in my editor.
Add the IAM role and Lambda function
Lambda needs an IAM role that grants it permission to run and write logs. You will create that role first, then define the Lambda function that uses it.
- In terraform/main.tf, add the IAM role and policy attachment below the CloudWatch log groups:
# IAM Role for Lambda
resource "aws_iam_role" "lambda" {
name = "${var.project_name}-lambda-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" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
What is an assume-role trust policy?
The assume_role_policy tells AWS which services are allowed to "become" this role. Here, only lambda.amazonaws.com can assume it, so no other service or user can use these permissions.
The AWSLambdaBasicExecutionRole managed policy grants permission to write logs to CloudWatch. Without it, your function runs but produces no log output.
- Still in terraform/main.tf, add the Lambda function resource below the policy attachment:
# Lambda Function
resource "aws_lambda_function" "this" {
function_name = var.project_name
package_type = "Image"
image_uri = "${aws_ecr_repository.this.repository_url}:${var.image_tag}"
role = aws_iam_role.lambda.arn
timeout = 900
memory_size = 512
environment {
variables = {
OPENAI_API_KEY = var.openai_api_key
}
}
depends_on = [
aws_cloudwatch_log_group.lambda
]
}
Why 900 seconds and 512 MB?
Clinical note processing involves multiple agent handoffs to OpenAI. Most of the time is spent waiting on API responses, so 512 MB of memory is sufficient. The timeout is set to 900 seconds (the Lambda maximum) to accommodate cold starts on container images plus sequential agent calls.
The package_type = "Image" tells Lambda to pull a container image from ECR instead of expecting a .zip file. The image_uri references the ECR repository you defined above, tagged with var.image_tag.
- Save terraform/main.tf.
Seeing a red squiggly on var references?
The var.aws_region, var.project_name, and other variable references will show as undefined until you create variables.tf in the next substep. This is expected.
Help me understand why my Terraform variables are showing errors.
Create variables.tf and outputs.tf
Terraform variables make your configuration reusable. Instead of hardcoding values like your AWS region or project name, you define them once and reference them everywhere.
- Create a new file called variables.tf inside the terraform/ directory, then paste the following:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "clinical-note-agent"
}
variable "openai_api_key" {
description = "OpenAI API key for the agent"
type = string
sensitive = true
}
variable "image_tag" {
description = "Docker image tag to deploy"
type = string
default = "latest"
}
What does sensitive = true do?
Marking openai_api_key as sensitive prevents Terraform from printing its value in CLI output or plan logs. The key is still stored in the Terraform state file, but it never appears on screen during terraform plan or terraform apply.
- Save terraform/variables.tf.
- Create another file called outputs.tf inside the terraform/ directory, then paste the following:
output "ecr_repository_url" {
description = "ECR repository URL for pushing images"
value = aws_ecr_repository.this.repository_url
}
output "lambda_function_name" {
description = "Lambda function name"
value = aws_lambda_function.this.function_name
}
output "api_endpoint_url" {
description = "API Gateway endpoint URL"
value = "${aws_api_gateway_stage.prod.invoke_url}/process"
}
output "api_key_value" {
description = "API key value for authenticating requests"
value = aws_api_gateway_api_key.this.value
sensitive = true
}
What are Terraform outputs?
Outputs surface important values after Terraform finishes deploying. After terraform apply completes, you will see the ECR URL (for pushing images) and the API endpoint URL (for sending requests) printed in your terminal.
The last two outputs reference API Gateway resources you will define in the next step. Terraform will flag them as unresolved until then.
- Save terraform/outputs.tf.
- Initialize Terraform by running this command from the terraform/ directory:
cd terraform
terraform init
What does terraform init do?
This downloads the AWS provider plugin (hashicorp/aws version 6.x) and creates a .terraform/ directory to store it locally. You must run init before any other Terraform command.
- Validate your configuration syntax by running:
terraform validate
You will see errors about aws_api_gateway_stage.prod and aws_api_gateway_api_key.this being undeclared. This is expected because those resources are defined in the next step. The important thing is that your provider, variables, ECR, CloudWatch, IAM, and Lambda blocks have no syntax errors.
Seeing errors other than the API Gateway references?
If you see errors mentioning var.project_name or var.aws_region, confirm your variables.tf file is saved in the same terraform/ directory as main.tf. Terraform reads all .tf files in the directory as one configuration.
Help me debug terraform validate errors in my configuration.
✔️ Awesome, I've got everything!
Great. Confirm you have saved all three files: terraform/main.tf, terraform/variables.tf, and terraform/outputs.tf.
ⓧ I'd like to double check the full code
Here is the full content of each file:
terraform {
required_version = ">= 1.13.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# ECR Repository
resource "aws_ecr_repository" "this" {
name = var.project_name
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
# CloudWatch Log Group for Lambda
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${var.project_name}"
retention_in_days = 14
}
# CloudWatch Log Group for API Gateway
resource "aws_cloudwatch_log_group" "api_gw" {
name = "/aws/apigateway/${var.project_name}"
retention_in_days = 14
}
# IAM Role for Lambda
resource "aws_iam_role" "lambda" {
name = "${var.project_name}-lambda-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" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Lambda Function
resource "aws_lambda_function" "this" {
function_name = var.project_name
package_type = "Image"
image_uri = "${aws_ecr_repository.this.repository_url}:${var.image_tag}"
role = aws_iam_role.lambda.arn
timeout = 900
memory_size = 512
environment {
variables = {
OPENAI_API_KEY = var.openai_api_key
}
}
depends_on = [
aws_cloudwatch_log_group.lambda
]
}
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "clinical-note-agent"
}
variable "openai_api_key" {
description = "OpenAI API key for the agent"
type = string
sensitive = true
}
variable "image_tag" {
description = "Docker image tag to deploy"
type = string
default = "latest"
}
output "ecr_repository_url" {
description = "ECR repository URL for pushing images"
value = aws_ecr_repository.this.repository_url
}
output "lambda_function_name" {
description = "Lambda function name"
value = aws_lambda_function.this.function_name
}
output "api_endpoint_url" {
description = "API Gateway endpoint URL"
value = "${aws_api_gateway_stage.prod.invoke_url}/process"
}
output "api_key_value" {
description = "API key value for authenticating requests"
value = aws_api_gateway_api_key.this.value
sensitive = true
}
Your core infrastructure is defined. Next, you will add the API Gateway layer that exposes your Lambda function as a public REST endpoint with API key authentication.
Add the API Gateway Layer
Your Lambda function is ready to process clinical notes, but it has no way for the outside world to reach it. Without a public endpoint, your function just sits idle inside AWS.
In this step, you will build a complete API Gateway REST API in front of Lambda. The gateway creates a public URL, routes POST requests to your function, and protects the endpoint with an API key and rate limiting.
In this step, get ready to:
- Define the REST API resource with Lambda proxy integration.
- Configure deployment, staging, and Lambda invocation permissions.
- Add API key authentication with a usage plan and rate limiting.
Define the API Gateway REST API with Lambda integration
API Gateway REST API is the type of gateway that supports API key authentication with usage plans. You need four resources: the API itself, a path resource, a method, and an integration that connects the method to Lambda.
- In terraform/main.tf, scroll to the bottom of the file (below the aws_lambda_function resource).
- Add the REST API, path resource, method, and Lambda integration by pasting this code at the end of the file:
# API Gateway REST API
resource "aws_api_gateway_rest_api" "this" {
name = "${var.project_name}-api"
api_key_source = "HEADER"
endpoint_configuration {
types = ["REGIONAL"]
}
}
# API Gateway Resource: /process
resource "aws_api_gateway_resource" "process" {
rest_api_id = aws_api_gateway_rest_api.this.id
parent_id = aws_api_gateway_rest_api.this.root_resource_id
path_part = "process"
}
# API Gateway Method: POST /process
resource "aws_api_gateway_method" "post_process" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.process.id
http_method = "POST"
authorization = "NONE"
api_key_required = true
}
# API Gateway Integration with Lambda
resource "aws_api_gateway_integration" "lambda" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.process.id
http_method = aws_api_gateway_method.post_process.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.this.invoke_arn
}
What does this code do?
aws_api_gateway_rest_api creates the API itself. Setting api_key_source = "HEADER" means clients send their key in the x-api-key HTTP header. The REGIONAL endpoint type avoids the overhead of CloudFront that EDGE endpoints add.
aws_api_gateway_resource defines the /process URL path. aws_api_gateway_method sets it up for POST requests and enforces an API key.
aws_api_gateway_integration is the bridge between the method and your Lambda function. The AWS_PROXY type passes the full request (headers, body, query params) straight into your Lambda event object.
Why REST API instead of HTTP API?
AWS offers two flavours of API Gateway: REST API and HTTP API. HTTP APIs are cheaper and simpler, but they do not support API key validation with usage plans.
Since this project needs key-based authentication and rate limiting via usage plans, REST API is the only option that supports both natively.
- Save terraform/main.tf.
You won't see a visible change yet. The next two chunks complete the API configuration, and then you will validate everything together.
Define deployment, stage, and Lambda permission
An API Gateway REST API does not become reachable until you create a deployment and assign it to a stage. You also need to explicitly grant API Gateway permission to invoke your Lambda function.
- In terraform/main.tf, add the deployment, stage, and Lambda permission resources below the integration block you just pasted:
# API Gateway Deployment
resource "aws_api_gateway_deployment" "this" {
rest_api_id = aws_api_gateway_rest_api.this.id
triggers = {
redeployment = sha1(jsonencode([
aws_api_gateway_resource.process.id,
aws_api_gateway_method.post_process.id,
aws_api_gateway_integration.lambda.id
]))
}
lifecycle {
create_before_destroy = true
}
}
# API Gateway Stage
resource "aws_api_gateway_stage" "prod" {
deployment_id = aws_api_gateway_deployment.this.id
rest_api_id = aws_api_gateway_rest_api.this.id
stage_name = "prod"
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_gw.arn
format = jsonencode({
requestId = "$context.requestId"
sourceIp = "$context.identity.sourceIp"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
resourcePath = "$context.resourcePath"
status = "$context.status"
responseLength = "$context.responseLength"
})
}
}
# Lambda Permission for API Gateway
resource "aws_lambda_permission" "api_gw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.this.execution_arn}/*/*"
}
What does this code do?
The deployment resource snapshots your API configuration. The triggers block uses a hash of dependent resource IDs so Terraform automatically redeploys when you change the method or integration.
The stage resource names the deployment prod and configures structured access logging to the CloudWatch log group you created in the previous step.
aws_lambda_permission is a resource-based policy on your Lambda function. Without it, API Gateway receives a 403 when it tries to invoke your function, even though the integration is correctly configured.
- Save terraform/main.tf.
You won't see a change yet. The final chunk adds the API key and usage plan, then you will validate the full configuration.
Define API key, usage plan, and association
An API key alone does nothing until you attach it to a usage plan that links to your deployed stage. The usage plan also lets you set rate limits and daily quotas so a single consumer cannot overwhelm your Lambda.
- In terraform/main.tf, add the API key, usage plan, and association resources below the Lambda permission block:
# API Key
resource "aws_api_gateway_api_key" "this" {
name = "${var.project_name}-key"
enabled = true
}
# Usage Plan
resource "aws_api_gateway_usage_plan" "this" {
name = "${var.project_name}-usage-plan"
api_stages {
api_id = aws_api_gateway_rest_api.this.id
stage = aws_api_gateway_stage.prod.stage_name
}
throttle_settings {
burst_limit = 10
rate_limit = 5
}
quota_settings {
limit = 1000
period = "DAY"
}
}
# Associate API Key with Usage Plan
resource "aws_api_gateway_usage_plan_key" "this" {
key_id = aws_api_gateway_api_key.this.id
key_type = "API_KEY"
usage_plan_id = aws_api_gateway_usage_plan.this.id
}
What does this code do?
aws_api_gateway_api_key creates a key that clients must include in the x-api-key header. The enabled = true flag makes it immediately active.
The usage plan ties the key to your prod stage and sets two guardrails: a burst limit of 10 concurrent requests, a steady-state rate of 5 requests per second, and a hard cap of 1,000 requests per day.
aws_api_gateway_usage_plan_key is the final glue that associates the key with the plan. Without this resource, the key exists but is not enforced.
- Save terraform/main.tf.
- Validate the configuration by running:
terraform validate
- You should see Success! The configuration is valid. in the output.
- Run a plan to preview what Terraform will create:
terraform plan
- You should see a plan listing new resources to add (the API Gateway resources, API key, usage plan, and Lambda permission). No errors should appear.
Seeing a validation error?
- Check that every rest_api_id reference points to aws_api_gateway_rest_api.this.id. A typo in the resource name causes an unknown reference error.
- Make sure the aws_cloudwatch_log_group.api_gw resource from the previous step still exists. The stage references it in access_log_settings.
- Confirm you are running terraform validate from inside the terraform/ directory.
Help me debug a Terraform API Gateway validation error
✔️ Awesome, I've got everything!
Great. Double check you have saved terraform/main.tf and both commands completed without errors.
ⓧ I'd like to double check the full code
Here is the complete terraform/main.tf file at this point in the project:
terraform {
required_version = ">= 1.13.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_ecr_repository" "this" {
name = var.project_name
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${var.project_name}"
retention_in_days = 14
}
resource "aws_cloudwatch_log_group" "api_gw" {
name = "/aws/apigateway/${var.project_name}"
retention_in_days = 14
}
resource "aws_iam_role" "lambda" {
name = "${var.project_name}-lambda-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" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "this" {
function_name = var.project_name
package_type = "Image"
image_uri = "${aws_ecr_repository.this.repository_url}:${var.image_tag}"
role = aws_iam_role.lambda.arn
timeout = 900
memory_size = 512
environment {
variables = {
OPENAI_API_KEY = var.openai_api_key
}
}
depends_on = [
aws_cloudwatch_log_group.lambda
]
}
resource "aws_api_gateway_rest_api" "this" {
name = "${var.project_name}-api"
api_key_source = "HEADER"
endpoint_configuration {
types = ["REGIONAL"]
}
}
resource "aws_api_gateway_resource" "process" {
rest_api_id = aws_api_gateway_rest_api.this.id
parent_id = aws_api_gateway_rest_api.this.root_resource_id
path_part = "process"
}
resource "aws_api_gateway_method" "post_process" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.process.id
http_method = "POST"
authorization = "NONE"
api_key_required = true
}
resource "aws_api_gateway_integration" "lambda" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.process.id
http_method = aws_api_gateway_method.post_process.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.this.invoke_arn
}
resource "aws_api_gateway_deployment" "this" {
rest_api_id = aws_api_gateway_rest_api.this.id
triggers = {
redeployment = sha1(jsonencode([
aws_api_gateway_resource.process.id,
aws_api_gateway_method.post_process.id,
aws_api_gateway_integration.lambda.id
]))
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_api_gateway_stage" "prod" {
deployment_id = aws_api_gateway_deployment.this.id
rest_api_id = aws_api_gateway_rest_api.this.id
stage_name = "prod"
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api_gw.arn
format = jsonencode({
requestId = "$context.requestId"
sourceIp = "$context.identity.sourceIp"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
resourcePath = "$context.resourcePath"
status = "$context.status"
responseLength = "$context.responseLength"
})
}
}
resource "aws_lambda_permission" "api_gw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.this.execution_arn}/*/*"
}
resource "aws_api_gateway_api_key" "this" {
name = "${var.project_name}-key"
enabled = true
}
resource "aws_api_gateway_usage_plan" "this" {
name = "${var.project_name}-usage-plan"
api_stages {
api_id = aws_api_gateway_rest_api.this.id
stage = aws_api_gateway_stage.prod.stage_name
}
throttle_settings {
burst_limit = 10
rate_limit = 5
}
quota_settings {
limit = 1000
period = "DAY"
}
}
resource "aws_api_gateway_usage_plan_key" "this" {
key_id = aws_api_gateway_api_key.this.id
key_type = "API_KEY"
usage_plan_id = aws_api_gateway_usage_plan.this.id
}
Your entire API layer is now defined as code. Next up, you will push your Docker image to ECR and deploy everything with a single terraform apply.
Deploy and Test the Live Endpoint
Your Terraform configuration is complete and validated. Now it's time to deploy everything to AWS and see your agent running in production.
You'll push your Docker image to ECR, deploy all infrastructure with a single command, and test the live API endpoint with real clinical notes.
In this step, get ready to:
- Push your Docker image to Amazon ECR.
- Deploy all AWS infrastructure with Terraform.
- Test the live API endpoint with and without authentication.
Push the Docker image to ECR
Before Terraform can create the Lambda function, the container image must already exist in ECR. You'll create just the ECR repository first, push your image to it, then deploy everything else.
- Create only the ECR repository resource by running this targeted apply from your terraform directory:
terraform apply -target=aws_ecr_repository.this
What does -target do?
The -target flag tells Terraform to create only the specified resource and its dependencies, ignoring everything else. This lets you get the ECR repository running so you can push an image before the Lambda function expects one.
- Terraform will show a plan and ask: "Do you want to perform these actions?" Type yes and press Enter.
- Once it completes, retrieve your ECR repository URL by running:
terraform output -raw ecr_repository_url
- Copy the URL that appears. It looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/clinical-note-agent. You'll use this in the next commands.
- Authenticate Docker to your ECR registry by running:
aws ecr get-login-password --region [[AWS_REGION="us-east-1"]] | docker login --username AWS --password-stdin [[ECR_REGISTRY="123456789012.dkr.ecr.us-east-1.amazonaws.com"]]
What is the ECR registry value?
The registry is your ECR repository URL without the repository name at the end. If your full URL is 123456789012.dkr.ecr.us-east-1.amazonaws.com/clinical-note-agent, the registry is everything before the /clinical-note-agent part.
- You should see Login Succeeded in the output.
Login failed or access denied?
- Make sure the region in the command matches the region you deployed to.
- Confirm your AWS credentials are still active by running aws sts get-caller-identity.
- Check that Docker Desktop is running.
Why can't I authenticate Docker to ECR?
- Tag your local image with the ECR repository URL by running:
docker tag clinical-note-agent:latest [[ECR_REPO_URL="123456789012.dkr.ecr.us-east-1.amazonaws.com/clinical-note-agent"]]:latest
- Push the tagged image to ECR by running:
docker push [[ECR_REPO_URL="123456789012.dkr.ecr.us-east-1.amazonaws.com/clinical-note-agent"]]:latest
- The push will take a few minutes depending on your internet speed. You should see layer-by-layer upload progress followed by a digest and size confirmation.
Push failing or timing out?
- If you see a "retrying" message, check your internet connection and try again.
- If you see "denied" or "no basic auth credentials", re-run the aws ecr get-login-password command above. The login token expires after 12 hours.
- Confirm you tagged the image with the correct ECR URL (it should match the terraform output value exactly).
Why is my Docker push to ECR failing?
Deploy all infrastructure
With the image in ECR, Terraform can now create the Lambda function that references it, along with the API Gateway, IAM role, and everything else.
- Create a terraform.tfvars file inside your terraform directory with your variable values. Paste the following content:
aws_region = "us-east-1"
project_name = "clinical-note-agent"
openai_api_key = "your-openai-api-key-here"
image_tag = "latest"
What does this file do?
The terraform.tfvars file supplies values for the variables defined in variables.tf. Terraform automatically loads any file named terraform.tfvars in the working directory.
Replace your-openai-api-key-here with your actual OpenAI API key from Project 1. Never commit this file to version control.
- Save terraform.tfvars.
- Deploy all remaining infrastructure by running:
terraform apply
- Terraform will show a plan listing all the resources it will create. Review the list and type yes when prompted.
- The deployment takes 1-2 minutes. When it finishes, you'll see the outputs including api_endpoint_url and lambda_function_name.
Why is api_key_value hidden?
The api_key_value output is marked sensitive in your configuration, so Terraform redacts it during apply. You'll retrieve it with a separate command next.
- Retrieve your API endpoint URL by running:
terraform output -raw api_endpoint_url
- Copy this URL. It looks like https://<api-id>.execute-api.us-east-1.amazonaws.com/prod/process.
- Retrieve your API key value by running:
terraform output -raw api_key_value
- Copy this value and keep it safe. You'll use it in the x-api-key header when making requests.
Deployment failed?
- If you see an error about the image not existing, confirm your push completed successfully with aws ecr describe-images --repository-name clinical-note-agent.
- If you see a permissions error, run aws sts get-caller-identity to confirm your credentials have the required IAM permissions.
- If the API Gateway stage fails, run terraform apply again. Some resources have ordering dependencies that resolve on a second run.
Why did my terraform apply fail?
Test the live endpoint
Your API is live. Time to send a real clinical note and confirm everything works end to end.
- Send an authenticated POST request to your endpoint by running this in PowerShell:
$headers = @{
"Content-Type" = "application/json"
"x-api-key" = "your-api-key-here"
}
$body = '{"clinical_note": "Patient John Smith, 45-year-old male, presents with persistent cough for 3 weeks, fatigue, and unintentional weight loss of 10 pounds. History of type 2 diabetes managed with metformin."}'
$response = Invoke-WebRequest -Uri "your-api-endpoint-url" -Method POST -Headers $headers -Body $body
$response.Content
What do I replace in this command?
Replace your-api-key-here with the value from terraform output -raw api_key_value. Replace your-api-endpoint-url with the URL from terraform output -raw api_endpoint_url.
- The first request may take 30-60 seconds due to a Lambda cold start. You should see a JSON response containing structured clinical extractions with ICD-10 codes.
Cold starts with container images
Container image Lambda functions have longer cold starts than zip-based deployments because AWS needs to pull and decompress the image on first invocation. Subsequent requests in the same session are much faster.
- Now test that authentication is enforced by sending a request without the API key:
try {
$noKeyResponse = Invoke-WebRequest -Uri "your-api-endpoint-url" -Method POST -Headers @{"Content-Type" = "application/json"} -Body '{"clinical_note": "Test note."}'
} catch {
$_.Exception.Response.StatusCode
}
- You should see Forbidden (status code 403). This confirms your API key authentication is working correctly.
Why use a try/catch block?
PowerShell's Invoke-WebRequest throws an exception for non-2xx status codes by default. Wrapping it in a try/catch lets you inspect the 403 response instead of seeing a red error message.
- Verify your CloudWatch logs are capturing structured JSON by opening the AWS Console.
- Navigate to CloudWatch > Log groups > /aws/lambda/clinical-note-agent.
- Open the most recent log stream. You should see structured JSON log entries with timestamp, level, and message fields.
Getting a 500 error or timeout?
- Check CloudWatch logs for the specific error. The most common issue is a missing or incorrect OPENAI_API_KEY environment variable.
- If you see an import error for agent_pipeline, confirm that your Project 1 agent code is in the src/ directory and was included in the Docker image.
- If the request times out, remember the Lambda has a 900-second (15 minute) timeout. Clinical note processing with multiple agent handoffs can take significant time on cold starts.
Help me debug my Lambda 500 error from CloudWatch logs.
Secret mission
GitHub Actions CI/CD Pipeline
Right now, deploying a code change means manually rebuilding the Docker image, pushing to ECR, and updating Lambda. What if every push to main triggered that entire flow automatically? In this secret mission, you will build a GitHub Actions workflow that does exactly that.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Lambda charges only per invocation and API Gateway includes 1 million calls per month in the free tier for the first 12 months, so costs are near-zero when idle.
Cost warning
If you leave these resources deployed and send requests to the endpoint, you will incur charges for Lambda invocations, ECR image storage, and CloudWatch log storage. Estimated cost is $0.50-$2.00/month with light usage.
If you are done experimenting, choose the Delete tab below to remove everything with a single command.
Resources you used:
- ECR Repository (stores your container images).
- Lambda Function (runs your agent on invocation).
- API Gateway REST API (fronts the Lambda with authentication).
- CloudWatch Log Groups (stores Lambda and API Gateway logs).
- IAM Role (grants Lambda execution permissions).
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or testing your endpoint.
- Lambda is serverless. You pay nothing while the function is idle.
- API Gateway free tier covers 1 million API calls per month for the first 12 months.
- ECR charges a small amount for image storage (typically a few cents per month for a single image).
- CloudWatch retains logs for 14 days based on the retention policy you configured.
Pause - I'll come back to this later
Lambda is serverless, so there is no process to stop. Your resources are effectively paused whenever you are not sending requests.
- No action is required. Lambda does not charge when idle.
- If you want to stop any possibility of accidental invocations, you can disable the API key in the AWS Console by navigating to API Gateway, then API Keys, selecting your key, and clicking Disable.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible.
- Delete all images from the ECR repository first (Terraform cannot delete a non-empty repository without the force_delete attribute). Run this command:
- If you only pushed a single latest tag, run:
aws ecr batch-delete-image --repository-name clinical-note-agent --image-ids imageTag=latest
- If you completed the Secret Mission and pushed images tagged with commit SHAs, list all images first:
aws ecr list-images --repository-name clinical-note-agent
- Then delete each image by its tag. For example:
aws ecr batch-delete-image --repository-name clinical-note-agent --image-ids imageTag=latest imageTag=abc1234
- Once the repository is empty, navigate to your terraform/ directory and destroy all AWS resources by running:
cd terraform
terraform destroy
- Terraform will show a plan listing all resources to be destroyed. Type yes when prompted to confirm.
- Delete the local project files from your Desktop:
cd ~/Desktop
rmdir /s /q clinical-note-agent
- If you set up GitHub repository secrets for the CI/CD pipeline, navigate to your repository on GitHub. Click Settings, then Secrets and variables, then Actions. Delete each secret (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, ECR_REPOSITORY_NAME, LAMBDA_FUNCTION_NAME).
That's a Wrap!
That's a Wrap!
Nice work! You just took your Clinical Note Intelligence Agent from a local prototype to a fully deployed, production-grade API on AWS.
You've learned how to:
- Containerized your agent with Docker and deployed it on AWS Lambda in container image mode via Amazon ECR.
- Built a complete Terraform codebase that provisions all infrastructure as code with a single terraform apply.
- Configured API Gateway REST API with API key authentication, rate limiting, and structured CloudWatch logging for observability.
- Secret Mission: Created a GitHub Actions CI/CD pipeline that automatically builds, pushes, and deploys on every push to main.
Ready to quiz yourself?