Type: Procedural Guide Last updated: 2026-06-18
- Overview
- Prerequisites
- Step-by-Step Instructions
- Verification
- Post-Deployment
- Cleanup
- Troubleshooting
- Cross-References
This guide walks you through deploying Patient Interaction Practice Tool from scratch. You will set up AWS prerequisites, configure secrets, deploy CDK stacks, and verify the deployment. The process covers the full lifecycle from initial setup through post-deployment configuration and teardown.
- AWS Account with administrative access
- AWS CLI v2 installed and configured (
aws configure) with a named profile - Node.js 22+ and npm
- AWS CDK CLI installed globally:
npm install -g aws-cdk - Git installed
- GitHub account with a fork of the Patient Interaction Practice Tool repository
- OpenSSL for generating the RSA key pair used in CloudFront signed URLs (pre-installed on macOS/Linux; on Windows, available via Git Bash or Win32 OpenSSL)
Note: Docker is not required locally. All Docker images are built in the cloud by CodePipeline/CodeBuild. Python is also not required locally since the Python Lambda functions and voice agent run in containers built remotely.
- Docker: only needed if you want to manually build and push container images locally (not required for normal deployment).
- Custom domain: configured automatically via the
SesVerifiedDomainCDK context variable. See Custom Domain & SES for details. - Amazon SES: for production email sending (verification emails). By default, Cognito uses its built-in email service (limited to 50 emails/day).
The CI/CD pipeline and Amplify app use a GitHub PAT to pull source code.
- Go to GitHub > Settings > Developer settings > Personal access tokens > Tokens (classic).
- Click Generate new token (classic).
- Select scopes:
repo(full control of private repositories) andadmin:repo_hook(for webhooks). - Copy the generated token. You will need it in Step 4.
Patient Interaction Practice Tool uses several Amazon Bedrock models. These models are available by default (no manual access request is needed). However, verify they are accessible in the regions used by the application.
The application deploys to ca-central-1 but makes cross-region calls to us-east-1 for certain models that are only available there:
| Model | Region | Purpose |
|---|---|---|
Anthropic Claude Sonnet 4.6 (us.anthropic.claude-sonnet-4-6) |
us-east-1 |
Primary LLM for text generation |
Cohere Embed v4 (cohere.embed-v4:0) |
us-east-1 |
Document and query embeddings |
Amazon Nova Sonic 2.0 (amazon.nova-2-sonic-v1:0) |
us-east-1 |
Voice interactions (via AgentCore) |
To verify access, open the Bedrock console in us-east-1 and confirm these models appear under Model access as available. If any model shows as unavailable, enable it there.
Note: The CDK stacks deploy to
ca-central-1, but the application routes LLM, embedding, and voice calls tous-east-1where these models are hosted. This cross-region routing is handled automatically by the application code.
You are not limited to these regions. The application can be deployed to any AWS region —
ca-central-1is simply the default used by the team. To deploy elsewhere, change the region incdk/bin/cdk.ts. The cross-region Bedrock calls tous-east-1will still work from any deployment region since the application explicitly targetsus-east-1for model invocations regardless of where the stacks live.
git clone https://github.com/<YOUR-GITHUB-USERNAME>/<REPO NAME HERE>.git
cd <REPO NAME HERE>/cdk
npm installBefore deploying, create the following secrets and parameters in AWS. These are referenced by the CDK stacks at synthesis time.
⚠️ Important — Always pass--profile: Every AWS CLI command in this guide requires the--profile <YOUR-AWS-PROFILE>flag to target the correct account. If you omit it, the CLI uses the default profile which may point to a different account.
⚠️ Important — JSON secrets: When creating secrets that contain JSON (likePIPTSecretsandgithub-personal-access-token), ensure the stored value has proper double quotes around keys and values. Shell escaping (especially on Windows) can silently corrupt JSON. After creating any JSON secret, verify it:aws secretsmanager get-secret-value --secret-id <SECRET-NAME> --region <YOUR-REGION> --profile <YOUR-AWS-PROFILE> --query SecretString --output textIf the output doesn't look like valid JSON, fix it via the AWS Console (Secrets Manager → select the secret → Retrieve secret value → Edit → Plaintext tab → paste the correct JSON → Save).
This secret contains the admin username for the RDS PostgreSQL instance. You choose this value; it becomes the master username for your database. The database stack reads DB_Username from this secret at deploy time.
macOS / Linux
aws secretsmanager create-secret \
--name PIPTSecrets \
--secret-string '{"DB_Username": "<YOUR-DB-ADMIN-USERNAME>"}' \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
aws secretsmanager create-secret `
--name PIPTSecrets `
--secret-string '{\"DB_Username\": \"<YOUR-DB-ADMIN-USERNAME>\"}' `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws secretsmanager create-secret ^
--name PIPTSecrets ^
--secret-string "{\"DB_Username\": \"<YOUR-DB-ADMIN-USERNAME>\"}" ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>Important: The RDS username must start with a letter, contain only alphanumeric characters, and be 1–63 characters long. Avoid reserved words like
admin,rds, orpostgres.
This secret is used by the CI/CD pipeline and Amplify to access your GitHub repository.
⚠️ Important — JSON formatting: The secret value must be valid JSON with double quotes around both the key and the value. It should look exactly like:{"my-github-token": "ghp_xxxx..."}. Shell escaping issues (especially on Windows) can silently corrupt the JSON. If the CLI gives you trouble, create/edit the secret via the AWS Console instead (see troubleshooting below).
macOS / Linux
aws secretsmanager create-secret \
--name github-personal-access-token \
--secret-string '{"my-github-token": "<YOUR-GITHUB-PAT>"}' \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
aws secretsmanager create-secret `
--name github-personal-access-token `
--secret-string '{\"my-github-token\": \"<YOUR-GITHUB-PAT>\"}' `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws secretsmanager create-secret ^
--name github-personal-access-token ^
--secret-string "{\"my-github-token\": \"<YOUR-GITHUB-PAT>\"}" ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>Alternative: Create/fix via AWS Console (recommended if CLI escaping is problematic)
- Go to Secrets Manager in the AWS Console → find
github-personal-access-token - Click on it → scroll to Secret value section
- Click Retrieve secret value
- Click Edit
- Switch to the Plaintext tab
- Replace the content with exactly:
{"my-github-token": "<YOUR-GITHUB-PAT>"} - Click Save
Make sure the key my-github-token and your token value both have double quotes around them.
Create an SSM parameter containing your GitHub username (the owner of the forked repository).
macOS / Linux
aws ssm put-parameter \
--name "pipt-owner-name" \
--value "<YOUR-GITHUB-USERNAME>" \
--type String \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
aws ssm put-parameter `
--name "pipt-owner-name" `
--value "<YOUR-GITHUB-USERNAME>" `
--type String `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws ssm put-parameter ^
--name "pipt-owner-name" ^
--value "<YOUR-GITHUB-USERNAME>" ^
--type String ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>Create a comma-separated list of email domains allowed to sign up (e.g., gmail.com,ubc.ca). The Cognito pre-signup Lambda reads this parameter to block registrations from unauthorized domains. Store it as a SecureString since it controls access.
Important: The value must be comma-separated with no spaces between domains. Use
gmail.com,ubc.ca, notgmail.com, ubc.ca. A space before a domain will cause sign-up validation to fail silently for that domain.
macOS / Linux
aws ssm put-parameter \
--name "/<YOUR-STACK-PREFIX>/AllowedEmailDomains" \
--value "<COMMA-SEPARATED-DOMAINS>" \
--type SecureString \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
aws ssm put-parameter `
--name "/<YOUR-STACK-PREFIX>/AllowedEmailDomains" `
--value "<COMMA-SEPARATED-DOMAINS>" `
--type SecureString `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws ssm put-parameter ^
--name "/<YOUR-STACK-PREFIX>/AllowedEmailDomains" ^
--value "<COMMA-SEPARATED-DOMAINS>" ^
--type SecureString ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>Example:
--value "gmail.com,ubc.ca"allows only users with emails from these domains to register. Add additional domains as needed, separated by commas.
The EcsSocket stack unconditionally reads this SSM parameter at deploy time to configure the voice agent WebSocket connection. Even though you won't have a real voice agent ARN until post-deployment, a placeholder value must exist before the first cdk deploy or CloudFormation will fail during the EcsSocket stack creation.
Create the placeholder now. You will update it with the real ARN after deploying the voice agent (see Deploy the Voice Agent).
macOS / Linux
aws ssm put-parameter \
--name "/<YOUR-STACK-PREFIX>/voiceAgentArn" \
--value "placeholder" \
--type String \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
aws ssm put-parameter `
--name "/<YOUR-STACK-PREFIX>/voiceAgentArn" `
--value "placeholder" `
--type String `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws ssm put-parameter ^
--name "/<YOUR-STACK-PREFIX>/voiceAgentArn" ^
--value "placeholder" ^
--type String ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>Example: If your
StackPrefixisPIPT, the parameter name is/PIPT/voiceAgentArn. Voice features will not function with the placeholder value (they require the real ARN set up in post-deployment), but the placeholder prevents the deployment from failing.
The API service stack uses CloudFront signed URLs to deliver patient documents securely. This requires an RSA key pair: the private key signs download URLs, and the public key lets CloudFront verify them. Without these, the {StackPrefix}-Api stack will fail to deploy because it reads /{StackPrefix}/CloudFrontPublicKey from SSM and references {StackPrefix}/CloudFrontSigningKey from Secrets Manager at synthesis time.
Why this is required: In cdk/lib/api-service-stack.ts, the stack calls ssm.StringParameter.valueForStringParameter(this, "/{StackPrefix}/CloudFrontPublicKey") to create a CloudFront PublicKey resource, and Lambda functions reference the {StackPrefix}/CloudFrontSigningKey secret at runtime to generate signed URLs. If either is missing, deployment fails.
Step 1: Generate an RSA 2048-bit key pair.
⚠️ Critical: The private key must be in PKCS#1 format (starts with-----BEGIN RSA PRIVATE KEY-----). OpenSSL 3.x defaults to PKCS#8 format (-----BEGIN PRIVATE KEY-----) which will not work — the Lambda function will fail at runtime withNo PEM start marker found. Always use the-traditionalflag to force PKCS#1 output.
macOS / Linux (or Git Bash on Windows)
openssl genrsa -traditional -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pemWindows — OpenSSL not available in PowerShell?
If openssl is not recognized in PowerShell or CMD, open a new terminal using Git Bash (installed with Git for Windows) and run the macOS/Linux commands above. Git Bash includes OpenSSL out of the box.
# In Git Bash (NOT PowerShell):
openssl genrsa -traditional -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pemVerify the key format: After generating, open
private_key.pemand confirm the first line is exactly-----BEGIN RSA PRIVATE KEY-----. If it says-----BEGIN PRIVATE KEY-----(without "RSA"), you forgot the-traditionalflag — regenerate it.
Step 2: Store the private key in Secrets Manager.
This is the signing key that Lambda functions use at runtime to generate time-limited signed URLs for document downloads.
macOS / Linux
aws secretsmanager create-secret \
--name "<YOUR-STACK-PREFIX>/CloudFrontSigningKey" \
--secret-string file://private_key.pem \
--description "RSA private key for signing CloudFront document delivery URLs" \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
$privateKey = Get-Content -Raw private_key.pem
aws secretsmanager create-secret `
--name "<YOUR-STACK-PREFIX>/CloudFrontSigningKey" `
--secret-string $privateKey `
--description "RSA private key for signing CloudFront document delivery URLs" `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws secretsmanager create-secret ^
--name "<YOUR-STACK-PREFIX>/CloudFrontSigningKey" ^
--secret-string file://private_key.pem ^
--description "RSA private key for signing CloudFront document delivery URLs" ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>Step 3: Store the public key in SSM.
CDK reads this at synthesis time to create the CloudFront PublicKey resource that verifies signed URLs.
macOS / Linux
aws ssm put-parameter \
--name "/<YOUR-STACK-PREFIX>/CloudFrontPublicKey" \
--value file://public_key.pem \
--type String \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
$publicKey = Get-Content -Raw public_key.pem
aws ssm put-parameter `
--name "/<YOUR-STACK-PREFIX>/CloudFrontPublicKey" `
--value $publicKey `
--type String `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws ssm put-parameter ^
--name "/<YOUR-STACK-PREFIX>/CloudFrontPublicKey" ^
--value file://public_key.pem ^
--type String ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>Security note: After uploading, delete the local key files (
rm private_key.pem public_key.pem). The private key is sensitive: anyone with access to it can generate signed URLs that bypass CloudFront access controls.
| Name | Type | Key/Value | Used By |
|---|---|---|---|
PIPTSecrets |
Secrets Manager | {"DB_Username": "..."} |
Database stack (RDS admin credentials) |
github-personal-access-token |
Secrets Manager | {"my-github-token": "..."} |
CI/CD stack, Amplify stack |
{StackPrefix}/CloudFrontSigningKey |
Secrets Manager | RSA private key (PEM) | Api stack (Lambda signed URL generation) |
pipt-owner-name |
SSM Parameter (String) | GitHub username | CI/CD stack, Amplify stack |
/{StackPrefix}/AllowedEmailDomains |
SSM Parameter (SecureString) | Comma-separated email domains | Cognito pre-signup Lambda |
/{StackPrefix}/CloudFrontPublicKey |
SSM Parameter (String) | RSA public key (PEM) | Api stack (CloudFront PublicKey resource) |
/{StackPrefix}/voiceAgentArn |
SSM Parameter (String) | placeholder (updated post-deployment) |
EcsSocket stack |
CDK must be bootstrapped in two regions: your deployment region and us-east-1.
Why both regions? The CloudFrontWafStack is deployed to us-east-1 because AWS requires CloudFront-scoped WAF Web ACLs to reside in us-east-1 regardless of where your application runs. The CDK app uses crossRegionReferences: true to pass the WAF ARN from the us-east-1 stack to the Api and EcsSocket stacks in your deployment region. This cross-region reference mechanism relies on CDK's bootstrap resources (S3 bucket, SSM parameters, IAM roles) existing in both regions. If us-east-1 is not bootstrapped, the {StackPrefix}-CloudFrontWaf stack deployment will fail with a "bootstrap stack not found" error.
# Bootstrap your primary deployment region
cdk bootstrap aws://<YOUR-ACCOUNT-ID>/<YOUR-REGION> \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c githubBranch=main \
--profile <YOUR-AWS-PROFILE>
# Bootstrap us-east-1 (required for the CloudFront WAF stack)
cdk bootstrap aws://<YOUR-ACCOUNT-ID>/us-east-1 \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c githubBranch=main \
--profile <YOUR-AWS-PROFILE>Note: If your deployment region IS
us-east-1, you only need to run the command once. The second bootstrap is only needed when deploying to a different region (e.g.,ca-central-1).
If bootstrapping fails, ensure you are also passing the CDK context flags (
-c StackPrefix,-c githubRepo,-c githubBranch) just like the deploy command. Without these, CDK may fail to synthesize the app before bootstrapping.
Optional — Rename the DynamoDB conversation table: The default table name is
DynamoDB-Conversation-Table(a legacy name from the forked codebase). If you're deploying fresh and want something more specific, change this one line incdk/lib/api-service-stack.tsbefore deploying:this.dynamoTableName = `${id}-DynamoDB-Conversation-Table`; // parameterized with your StackPrefixEverything else is parameterized from there — the custom resource creates whatever name you set, it gets written to the SSM parameter
/{id}/TableName, and the Python Lambdas read it from SSM at runtime. No other code changes needed.
The CDK app requires two context variables at deploy time, plus optional VPC configuration:
| Context Variable | Description | Required |
|---|---|---|
StackPrefix |
Prefix for all stack and resource names (e.g., PIPT) |
Yes |
githubRepo |
Name of your GitHub repository (not the full URL) | Yes |
githubBranch |
Branch to track for CI/CD (default: main) |
No |
voiceAgentArn |
ARN of a deployed Bedrock AgentCore voice agent (not needed for first deploy) | No |
SesVerifiedDomain |
Domain with a Route 53 hosted zone for SES email + Amplify custom domain | No |
SesIdentityVerified |
Set to "true" after SES domain is verified (see Custom Domain & SES) |
No |
SesSkipIdentityCreation |
Set to "true" to skip SES identity creation (when it already exists) |
No |
createRdsServiceLinkedRole |
Set to "true" on first deploy to a fresh AWS account that has never used RDS (creates the RDS service-linked role) |
No |
existingVpcId |
VPC ID to use an existing VPC instead of creating a new one (see VPC Configuration) | No |
controlTowerStackSet |
Control Tower StackSet name for importing subnet/route table exports | No |
existingPublicSubnetId |
ID of an existing public subnet (skips creating a new one) | No |
existingVpcCidr |
CIDR of the existing VPC (e.g., 172.31.128.0/20) |
No |
publicSubnetCidr |
CIDR for the new public subnet — must be a small slice within the VPC range (e.g., 172.31.128.240/28) |
No |
availabilityZones |
JSON array of AZ names (e.g., ["us-east-1a","us-east-1b","us-east-1c"]) |
No |
vpcCidr |
CIDR for a new VPC (default: 10.0.0.0/16) |
No |
maxAzs |
Number of availability zones for a new VPC (default: 2) |
No |
natGateways |
Number of NAT Gateways for a new VPC (default: 1; use 2 for prod HA) |
No |
Choose one of the following deployment options:
Tip: Avoid long, clunky deploy commands. Rather than passing every context variable on the CLI each time, you can put them once in the
contextsection ofcdk/cdk.jsonand then run a short deploy command (cdk deploy --all --profile <YOUR-AWS-PROFILE>). This is the recommended approach for repeated deploys — see the "Put context variables in cdk.json instead of long CLI commands" note under VPC Configuration. The-cflags shown in the options below are equivalent to setting those same keys incdk.json.
cdk deploy --all \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c githubBranch=main \
--profile <YOUR-AWS-PROFILE>First deploy to a brand-new AWS account? If this account has never used RDS before, add
-c createRdsServiceLinkedRole=trueto the command above. This creates the requiredAWSServiceRoleForRDSservice-linked role. Omit this flag on subsequent deploys or if the account already has RDS resources.
Stacks deploy in dependency order. If you only need to update a specific stack:
cdk deploy <YOUR-STACK-PREFIX>-Api \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c githubBranch=main \
--profile <YOUR-AWS-PROFILE>After completing the voice agent setup, you can pass the ARN explicitly on subsequent deploys. However, the recommended approach is to store it in SSM so you do not need this flag.
cdk deploy --all \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c githubBranch=main \
-c voiceAgentArn="arn:aws:bedrock:us-east-1:123456789012:agent-runtime/XXXXXXXXXX" \
--profile <YOUR-AWS-PROFILE>The CDK app creates the following stacks in dependency order:
{StackPrefix}-CICD: ECR repositories, CodeBuild projects, CodePipeline{StackPrefix}-VpcStack: VPC, subnets, NAT gateway, VPC endpoints{StackPrefix}-Database: RDS PostgreSQL instance, RDS Proxy, secrets{StackPrefix}-CloudFrontWaf: WAF Web ACL for CloudFront (deployed tous-east-1){StackPrefix}-Api: API Gateway, Lambda functions, Cognito, AppSync, S3, CloudFront{StackPrefix}-TurnServer: TURN server for WebRTC{StackPrefix}-EcsSocket: ECS Fargate service for Socket.IO{StackPrefix}-DBFlow: Database migration runner (triggers on deploy){StackPrefix}-Amplify: Amplify hosting for the React frontend
Note:
--allhandles the dependency order automatically. Deployment takes approximately 30–45 minutes on first run.
By default, CDK creates a brand-new VPC with public, private, and isolated subnets. If you need to deploy into an existing VPC (e.g., one created by AWS Control Tower Account Factory, or a shared-services VPC), you can configure this entirely through context variables — no source code edits required.
If you omit all VPC context variables, CDK creates a fresh VPC with:
- CIDR
10.0.0.0/16(override with-c vpcCidr=...) - 2 availability zones (override with
-c maxAzs=3) - 1 NAT Gateway (override with
-c natGateways=2for production high availability) - Public, private (with egress), and isolated subnets
NAT Gateway count. A single NAT Gateway is the default and is sufficient for this app. Only set
natGateways=2if you have a specific high-availability requirement.
# Example: new VPC (single NAT Gateway is sufficient for this app)
cdk deploy --all \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c maxAzs=3 \
--profile <YOUR-AWS-PROFILE>If your account was provisioned by AWS Control Tower (Account Factory), your VPC's subnet IDs, route tables, and CIDRs are exported as CloudFormation outputs by a StackSet. Provide the VPC ID and StackSet name:
cdk deploy --all \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c existingVpcId=vpc-0abc123def456789a \
-c controlTowerStackSet="StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
-c publicSubnetCidr="172.31.128.240/28" \
--profile <YOUR-AWS-PROFILE>Alternative: Put context variables in cdk.json instead of long CLI commands
If you don't want to deal with a long deploy command every time, add the context variables to the context section of cdk/cdk.json:
Then your deploy command becomes just:
cdk deploy --all --profile <YOUR-AWS-PROFILE>How to find your Control Tower StackSet name:
- Open the CloudFormation console.
- Go to Exports.
- Look for exports matching the pattern
StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-*-PrivateSubnet1AID. - The prefix before
-PrivateSubnet1AIDis your StackSet name.
Required context variables for this option:
| Variable | Description |
|---|---|
existingVpcId |
The VPC ID (e.g., vpc-0abc123...) |
controlTowerStackSet |
Full StackSet name including the GUID suffix |
publicSubnetCidr |
A small CIDR (e.g., /28) within your VPC range for the public subnet with IGW/NAT. Must not overlap with existing private subnets. |
Optional context variables:
| Variable | Default | Description |
|---|---|---|
existingPublicSubnetId |
"" (create new) |
If a public subnet already exists, provide its ID to skip creating IGW/NAT resources |
existingVpcCidr |
172.31.128.0/20 |
Your VPC's CIDR block (used for security group rules) |
availabilityZones |
Derived from stack environment | JSON array of AZ names if auto-detection isn't available |
If you have an existing VPC that was not created by Control Tower (no StackSet exports), you have two choices:
- Create matching CloudFormation exports manually that follow the Control Tower naming convention, then use Option 2.
- Use the default new-VPC path and peer/connect it to your existing networking as needed.
Note: A future enhancement will add
Vpc.fromLookup()support which automatically discovers subnets and route tables without requiring CloudFormation exports. For now, Option 2 with manual exports is the supported path for existing VPCs.
publicSubnetCidrmust be a small CIDR slice (e.g.,/27or/28), NOT the entire VPC CIDR. It needs to fit within the VPC range and must not overlap with existing subnets. For example, if your VPC is172.31.128.0/20, a good choice is172.31.143.240/28(the last 16 IPs in the range).- Availability zones are auto-detected from the stack's
env(account + region). You only need to passavailabilityZonesif CDK cannot resolve them (e.g., environment-agnostic synthesis without-corenv). - The
StackPrefixcontext variable is used for route naming in the existing-VPC branch. Ensure it's consistent across deploys to avoid orphaned routes.
If you previously deployed by editing the hardcoded values in vpc-stack.ts directly, you can migrate to the context-driven approach with a no-op deploy:
- Add the values you previously hardcoded to your
cdk.jsoncontext section:
{
"context": {
"StackPrefix": "<REPO NAME HERE>-production",
"existingVpcId": "vpc-0abc123...",
"controlTowerStackSet": "StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-df80d055-...",
"existingVpcCidr": "172.31.128.0/20",
"publicSubnetCidr": "172.31.128.0/20"
}
}Note: For existing deployments, set
publicSubnetCidrto the same value that was previously used (the VPC CIDR). This ensures CloudFormation sees no change. On future fresh deployments, use a proper small CIDR instead.
- Run
cdk diffto confirm no changes are detected:
cdk diff --all \
-c StackPrefix=<REPO NAME HERE>-production \
-c githubRepo=<REPO NAME HERE> \
-c existingVpcId=vpc-0abc123... \
-c controlTowerStackSet="StackSet-AWSControlTowerBP-..." \
-c existingVpcCidr="172.31.128.0/20" \
-c publicSubnetCidr="172.31.128.0/20" \
--profile <YOUR-AWS-PROFILE>- If the diff is clean (no resource changes), deploy to confirm:
cdk deploy --all \
-c StackPrefix=<REPO NAME HERE>-production \
-c githubRepo=<REPO NAME HERE> \
-c existingVpcId=vpc-0abc123... \
-c controlTowerStackSet="StackSet-AWSControlTowerBP-..." \
-c existingVpcCidr="172.31.128.0/20" \
-c publicSubnetCidr="172.31.128.0/20" \
--profile <YOUR-AWS-PROFILE>- Once confirmed, remove any manual edits from
vpc-stack.tsand rely solely on context going forward.
While the stacks deploy, you can monitor progress in real time through the AWS Console:
- Open the CloudFormation console in your deployment region.
- You will see each stack appear as it begins creating (e.g.,
{StackPrefix}-VpcStack,{StackPrefix}-Database, etc.). - Click into any stack and go to the Events tab to see individual resource creation progress.
- A stack showing
CREATE_IN_PROGRESSis still deploying. Wait forCREATE_COMPLETE. - If a stack shows
ROLLBACK_IN_PROGRESSorCREATE_FAILED, check the Events tab for the specific resource and error message that caused the failure. Do not attempt to delete or redeploy the stack until it reachesROLLBACK_COMPLETE. CloudFormation must finish rolling back all provisioned resources before it can accept new operations on that stack.
Tip: Also check the CloudFormation console in us-east-1 for the
{StackPrefix}-CloudFrontWafstack, since it deploys to that region separately.
After deployment completes, verify the following:
-
Check stack status. Open the CloudFormation console and confirm all stacks show
CREATE_COMPLETEorUPDATE_COMPLETE. -
Verify Bedrock access. Open the Lambda console, find the function named
{StackPrefix}-Api-TextGenLambdaDockerFunction, create a test event, and invoke it. Check CloudWatch Logs for successful initialization. -
Confirm API Gateway. Navigate to the API Gateway console and verify the
{StackPrefix}REST API exists with deployed stages. -
Check ECS service. Open the ECS console and confirm the socket server service has running tasks.
-
Verify database connectivity. Check CloudWatch Logs for the DBFlow Lambda to confirm migrations ran successfully.
The CI/CD pipeline builds and pushes all Docker images (text generation, data ingestion, socket server, voice agent). For the first deployment, the ECR repositories are empty. Trigger the pipeline by either:
- Pushing a commit to your tracked branch, or
- Clicking "Release change" in the CodePipeline console for the
{StackPrefix}-CICD-DockerImagePipelinepipeline
Wait for the pipeline to complete successfully. Once done, the Lambda functions and ECS services will have images to run.
Expected behavior: Until the pipeline finishes pushing the
socketServerimage, the ECS socket service will showSTOPPEDtasks withCannotPullContainerError. This is normal on first deployment. The service retries automatically and will stabilize once the image is available in ECR (typically 10–15 minutes after the pipeline starts). Do not manually intervene or delete the service.
On a fresh deploy in a new account/region, everything is handled automatically:
- A custom resource (
EnsureConversationTable) creates the table during CloudFormation deployment. - TTL is enabled on the
expireAtattribute as part of the same operation. - All downstream Lambdas have the table ready before they ever run.
- Other DynamoDB tables in the account are completely unaffected — both the table creation and TTL enablement target this single table by name. The custom resource's IAM policy is scoped to
arn:aws:dynamodb:<region>:<account>:table/DynamoDB-Conversation-Tableonly. TTL is a per-table setting in DynamoDB, not an account-wide setting — enabling it here does not touch any other table in your account.
The DynamoDB-Conversation-Table is not owned by CDK. This project was forked from an earlier codebase where the table was created manually in the AWS console and never linked to CloudFormation. Importing it into CDK (cdk import) was attempted but failed repeatedly, so CDK only references the table (via Table.fromTableName(...)) for IAM policies, Lambda environment variables, and CloudWatch alarms — it does not manage its lifecycle.
To handle the case where the table doesn't exist yet (fresh deploy), the stack includes a custom resource that runs on every deploy:
- Calls
CreateTablefor the configured table name (passed via environment variable, not hardcoded) with PAY_PER_REQUEST billing and key =SessionId. - If the table already exists, catches
ResourceInUseExceptionand does nothing — completely idempotent. - If the table does not exist, creates it, waits for
ACTIVEstatus, then enables TTL onexpireAt.
Because CDK doesn't own the table, cdk destroy will not delete it or its data.
This only applies if the table was created before the custom resource existed (i.e., it was manually created in the console without TTL). In that scenario the custom resource sees the table already exists, catches the exception, and skips everything — including TTL setup.
Check if TTL is already enabled: DynamoDB console → select DynamoDB-Conversation-Table > Additional settings tab > look for "Time to live attribute: expireAt (Enabled)". If it shows enabled, you're done.
If TTL is not enabled on a pre-existing table, run the following once:
macOS / Linux
aws dynamodb update-time-to-live \
--table-name DynamoDB-Conversation-Table \
--time-to-live-specification "Enabled=true, AttributeName=expireAt" \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
aws dynamodb update-time-to-live `
--table-name DynamoDB-Conversation-Table `
--time-to-live-specification "Enabled=true, AttributeName=expireAt" `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws dynamodb update-time-to-live ^
--table-name DynamoDB-Conversation-Table ^
--time-to-live-specification "Enabled=true, AttributeName=expireAt" ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>DynamoDB automatically deletes expired items in the background:
- Question, DTP, and recommendation cache items after 7 days
- Chat history items after 90 days
| Concern | Answer |
|---|---|
| Will this affect other DynamoDB tables in my account? | No. The Lambda's IAM policy only grants access to the configured table (default: DynamoDB-Conversation-Table). The table name is parameterized via an environment variable, not hardcoded. Every API call targets that single table by name. Other tables are untouched. |
Will cdk destroy delete the table? |
No. CDK references it via fromTableName, it doesn't own the resource. The table and all its data survive stack deletion. |
If you need to send more than 50 verification emails per day, SES is configured via CDK context variables. See Custom Domain & SES for the full two-step deployment process, custom domain setup, and troubleshooting.
After the first deployment, Amplify needs to run its initial build:
- Open the Amplify console.
- Find your app (named
{StackPrefix}-Amplify-amplify). - If the build has not triggered automatically, click Run build on the
mainbranch. - Wait for the build to complete (typically 3–5 minutes).
If the Amplify Console UI fails to detect branches (common when using a PAT-based connection instead of the GitHub App integration), you can create the branch and trigger a build entirely via CLI:
Get your Amplify App ID:
aws amplify list-apps \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE> \
--query "apps[?contains(name, '<YOUR-STACK-PREFIX>')].appId" \
--output textCreate the branch:
aws amplify create-branch \
--app-id <APP_ID> \
--branch-name <BRANCH_NAME> \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Trigger a build:
aws amplify start-job \
--app-id <APP_ID> \
--branch-name <BRANCH_NAME> \
--job-type RELEASE \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Check build status:
aws amplify list-jobs \
--app-id <APP_ID> \
--branch-name <BRANCH_NAME> \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Get the Amplify URL:
aws amplify get-branch \
--app-id <APP_ID> \
--branch-name <BRANCH_NAME> \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE> \
--query "branch.displayName"The app will be available at https://<BRANCH_NAME>.<APP_ID>.amplifyapp.com.
On a fresh deployment there are no admin users. Every account that signs up through the app is automatically assigned the student role by the post-confirmation Lambda. The CDK Api stack creates an admin Cognito group, but it starts empty — so the person who deployed the infrastructure must promote the first admin manually. There is no admin yet to do this from inside the app, so it has to be done from the AWS console (or CLI).
Once the first admin exists, they can promote existing users to instructors and manage the platform from the app UI — see the Admin Workflow in the User Guide.
Steps:
-
Sign up through the app. Open the deployed app URL and create the account you want to become the admin (using an email on one of your
AllowedEmailDomains). Confirm the email and log in once so the user is fully created in Cognito. This account starts as a student. -
Open the Cognito user pool. In the AWS console, go to Amazon Cognito > User pools and select the pool named
{StackPrefix}-UserPool. -
Add the user to the
admingroup. Open the Groups tab, click the admin group, choose Add user to group, and select the user you just created. -
Log out and log back in. The role is carried in the
cognito:groupsclaim of the user's token, which is only refreshed at login. The user must sign out of the app and sign back in for the admin role to take effect. Until they re-authenticate they'll still be treated as a student.
Alternative: promote via AWS CLI
Look up the user pool ID (or grab it from the {StackPrefix}-Api-UserPoolIdOutput CloudFormation output), then add the user to the admin group:
# Find the user pool ID
aws cognito-idp list-user-pools \
--max-results 60 \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE> \
--query "UserPools[?contains(Name, '<YOUR-STACK-PREFIX>')].{Name:Name,Id:Id}"
# Add the user to the admin group
aws cognito-idp admin-add-user-to-group \
--user-pool-id <USER_POOL_ID> \
--username <USER-EMAIL> \
--group-name admin \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>The user still needs to log out and log back in for the change to take effect.
Note: this is the only place the Cognito
admingroup is used. It exists purely to bootstrap the first admin. When the user logs back in, the app detects theadmingroup claim once and persistsadmininto the databaseusers.rolesarray. From that point on, the database is the single source of truth for authorization — all role checks (admin, instructor, student) read fromusers.roles, not from Cognito groups. Subsequent admins and instructors are managed entirely in-app and written to the database; you do not need to touch Cognito groups again. Removing a user from the Cognitoadmingroup after bootstrap does not revoke their admin access, since the role now lives in the database.
The voice agent runs on Amazon Bedrock AgentCore and is required for the voice mode functionality. It requires the CDK stacks to be deployed first (since the CI/CD pipeline builds and pushes the voice-agent Docker image to ECR). Follow this order of operations:
Deploy all stacks without a voice agent ARN (Steps 5–6 above). This creates the ECR repository for the voice agent image.
The CI/CD pipeline builds and pushes all Docker images, including the voice agent. Trigger it by either:
- Pushing a commit to your tracked branch, or
- Clicking "Release change" in the CodePipeline console for the
{StackPrefix}-CICD-DockerImagePipelinepipeline
Wait for the pipeline to complete successfully before proceeding.
Follow the detailed instructions in AgentCore Voice Agent Setup to configure Bedrock AgentCore and deploy the voice agent through the AWS console.
Once complete, you will have a voice agent runtime ARN.
Store the ARN in SSM so the EcsSocket stack can connect to it (this overwrites the placeholder created in Step 4):
macOS / Linux
aws ssm put-parameter \
--name "/<YOUR-STACK-PREFIX>/voiceAgentArn" \
--value "<YOUR-VOICE-AGENT-ARN>" \
--type String \
--overwrite \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Windows (PowerShell)
aws ssm put-parameter `
--name "/<YOUR-STACK-PREFIX>/voiceAgentArn" `
--value "<YOUR-VOICE-AGENT-ARN>" `
--type String `
--overwrite `
--region <YOUR-REGION> `
--profile <YOUR-AWS-PROFILE>Windows (CMD)
aws ssm put-parameter ^
--name "/<YOUR-STACK-PREFIX>/voiceAgentArn" ^
--value "<YOUR-VOICE-AGENT-ARN>" ^
--type String ^
--overwrite ^
--region <YOUR-REGION> ^
--profile <YOUR-AWS-PROFILE>Then redeploy the EcsSocket stack to pick up the new value:
cdk deploy <YOUR-STACK-PREFIX>-EcsSocket \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c githubBranch=main \
--profile <YOUR-AWS-PROFILE>Note: Voice features will not work until all four steps are complete. The ECS socket server uses the stored ARN to establish a SigV4-signed WebSocket connection to the AgentCore runtime.
Once the Amplify build completes, your app is live at the default Amplify domain:
https://main.<AMPLIFY-APP-ID>.amplifyapp.com
Find the exact URL in the Amplify console or in the CDK stack outputs:
aws cloudformation describe-stacks \
--stack-name <YOUR-STACK-PREFIX>-Amplify \
--query "Stacks[0].Outputs[?OutputKey=='AmplifyDefaultDomain'].OutputValue" \
--output text \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>To tear down all deployed resources, you must first disable termination protection on the critical stacks, then destroy them.
The VPC and Api stacks have CloudFormation termination protection enabled. You must disable it before cdk destroy will work:
- Open the CloudFormation console in your deployment region.
- For each of these stacks —
{StackPrefix}-VpcStack,{StackPrefix}-Api:- Select the stack.
- Click Stack actions → Edit termination protection.
- Set to Disabled and confirm.
The RDS instance itself also has deletion protection enabled (separate from stack termination protection):
- Open the RDS console.
- Select the database instance.
- Click Modify.
- Uncheck Enable deletion protection.
- Apply immediately.
cdk destroy --all \
-c StackPrefix=<YOUR-STACK-PREFIX> \
-c githubRepo=<REPO NAME HERE> \
-c githubBranch=main \
--profile <YOUR-AWS-PROFILE>Note: S3 buckets have
removalPolicy: RETAIN, so you need to empty and delete them manually after stack deletion.
To delete individual stacks, destroy them in reverse dependency order:
cdk destroy <YOUR-STACK-PREFIX>-Amplify -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>
cdk destroy <YOUR-STACK-PREFIX>-DBFlow -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>
cdk destroy <YOUR-STACK-PREFIX>-EcsSocket -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>
cdk destroy <YOUR-STACK-PREFIX>-TurnServer -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>
cdk destroy <YOUR-STACK-PREFIX>-Api -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>
cdk destroy <YOUR-STACK-PREFIX>-CloudFrontWaf -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>
cdk destroy <YOUR-STACK-PREFIX>-Database -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>
cdk destroy <YOUR-STACK-PREFIX>-VpcStack -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>
cdk destroy <YOUR-STACK-PREFIX>-CICD -c StackPrefix=<YOUR-STACK-PREFIX> -c githubRepo=<REPO NAME HERE> -c githubBranch=main --profile <YOUR-AWS-PROFILE>Cause: The RDS instance has deletion protection enabled, which prevents CloudFormation from deleting it.
Fix:
- Disable RDS deletion protection (see Step 2 in Cleanup).
- Retry
cdk destroy.
Cause: The DB_Username value in PIPTSecrets uses a reserved word or invalid characters.
Fix: Update the secret with a valid username (starts with a letter, alphanumeric only, 1–63 chars):
aws secretsmanager update-secret \
--secret-id PIPTSecrets \
--secret-string '{"DB_Username": "piptadmin"}' \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Cause: Environment variables are not available during build, or the GitHub token is invalid.
Fix:
- Verify the
github-personal-access-tokensecret exists and contains a valid token. - Check that the token has
reposcope. - Verify the repository name matches the
githubRepocontext variable.
Cause: The GitHub PAT has expired or lacks required permissions.
Fix:
- Generate a new GitHub PAT with
repoandadmin:repo_hookscopes. - Update the secret:
aws secretsmanager update-secret \
--secret-id github-personal-access-token \
--secret-string '{"my-github-token": "<NEW-TOKEN>"}' \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Cause: On some systems (particularly Windows), escape characters can cause the secret value to be stored as malformed JSON rather than a proper {"my-github-token": "..."} object. This happens due to differences in how shells handle quotes and escape characters across Windows CMD, PowerShell, macOS, and Linux.
Symptoms: CodePipeline or Amplify fails with authentication errors even though the token is correct. Retrieving the secret value shows it is not valid JSON (e.g., extra backslashes, missing quotes, or the string stored as plain text instead of a JSON object).
Fix: Delete the secret and recreate it:
# Delete the malformed secret (force immediate deletion)
aws secretsmanager delete-secret \
--secret-id github-personal-access-token \
--force-delete-without-recovery \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>
# Wait a few seconds, then recreate it
aws secretsmanager create-secret \
--name github-personal-access-token \
--secret-string '{"my-github-token": "<YOUR-GITHUB-PAT>"}' \
--region <YOUR-REGION> \
--profile <YOUR-AWS-PROFILE>Tip: After creating the secret, verify the stored value is valid JSON by retrieving it:
aws secretsmanager get-secret-value --secret-id github-personal-access-token --region <YOUR-REGION> --profile <YOUR-AWS-PROFILE> --query SecretString --output textThe output should be exactly:
{"my-github-token": "ghp_xxxxx..."}
Cause: ECR repositories are empty. The Docker Lambda functions have no image to run.
Fix: Push initial images by triggering the CI/CD pipeline (see Push Initial Docker Images) or push a commit to the tracked branch.
Cause: The ECS service starts immediately after the stack deploys, but the ECR repository for socketServer is empty until the CI/CD pipeline finishes building and pushing the image. The service cannot pull a container image that doesn't exist yet, so tasks fail with CannotPullContainerError and restart repeatedly.
Fix: This resolves itself. Once the CI/CD pipeline pushes the socketServer image to ECR, the ECS service will pull it on the next retry and stabilize. No manual action is needed. If tasks are still failing 20+ minutes after the pipeline completes successfully, check that the image tag in ECR matches what the task definition expects (latest).
Cause: Nova Sonic models are only available in us-east-1. If your deployment region is different, the voice service makes cross-region calls. The voice agent must also be deployed to Bedrock AgentCore and its ARN configured.
Fix:
- Ensure Bedrock model access is enabled in
us-east-1for Nova Sonic models. - Verify the ECS task role has
bedrock:InvokeModelWithBidirectionalStreampermission inus-east-1. - Confirm the voice agent is deployed to Bedrock AgentCore (see Deploy the Voice Agent).
- Verify the
voiceAgentArnis set, either via the-ccontext flag or the/{StackPrefix}/voiceAgentArnSSM parameter.
Cause: Claude Sonnet 4.6 and Cohere Embed v4 are called in us-east-1 via cross-region inference, but the models may not be accessible there.
Fix:
- Open the Bedrock console in us-east-1.
- Navigate to Model access and verify the models are available.
- Check CloudWatch Logs for the
TextGenLambdaDockerFunctionfor specific error messages. - Ensure the Lambda execution role has
bedrock:InvokeModelpermissions for the model ARNs inus-east-1.
- AgentCore Voice Agent Setup : Console-side voice agent configuration
- Database Migrations : Creating and running schema changes
- Modification Guide : Customizing colors, API, LLM, and frontend
- Custom Domain & SES : SES email delivery, custom domain, and Amplify custom domain
{ "context": { "StackPrefix": "<YOUR-STACK-PREFIX>", "githubRepo": "<YOUR-GITHUB-REPO>", "githubBranch": "<YOUR-BRANCH>", "existingVpcId": "<YOUR-VPC-ID>", "controlTowerStackSet": "<YOUR-CONTROL-TOWER-STACKSET-NAME>", "existingVpcCidr": "<YOUR-VPC-CIDR>", "publicSubnetCidr": "<YOUR-PUBLIC-SUBNET-CIDR>", "existingPublicSubnetId": "", "availabilityZones": ["<AZ-1>", "<AZ-2>", "<AZ-3>"], "skipVpcEndpoints": true } }