Skip to main content

AWS Infrastructure

ChartChat's AWS infrastructure is managed with Terraform in a separate repository (chartchat-terraform/chartchat-infra). There are three Terraform workspaces — UAT, Production, and Shared — each with its own state.


Workspaces

WorkspaceDirectoryTerraform state keyPurpose
UATaws/uat/uat/terraform.tfstateUAT environment
Productionaws/prod/prod/terraform.tfstateProduction environment
Sharedaws/shared/shared/terraform.tfstateShared resources (LLM server)

Terraform State

State is stored remotely in S3 so the whole team shares the same state:

SettingValue
Bucketchartchat-terraform-state-184995899457-us-east-2-an
Regionus-east-2

Create this bucket manually once before running terraform init for the first time.


Region

All resources are deployed in us-east-2 (Ohio).


EC2 — App Server

SettingUATProduction
Instance typet3a.larget3a.large
Root volume60 GB gp3AMI default
AMISet in terraform.tfvarsSet in terraform.tfvars
Elastic IPExisting EIP reusedExisting EIP reused
Termination protectionEnabledEnabled
IAM rolechartchat-uat-ec2-rolechartchat-prod-ec2-role

The EC2 instance runs all services:

  • nginx (reverse proxy + static file serving)
  • PM2 (Node.js server process manager)
  • FastAPI AI tools server
  • Jenkins (both environments — port 8080)

Elastic IP

Terraform looks up the existing EIP by public IP address and associates it with the newly created EC2 instance on every terraform apply. The EIP is never destroyed — it is permanent.

AMI lifecycle

The ami field in the EC2 resource has ignore_changes = [ami] — Terraform creates the instance with the specified AMI but never replaces it if the AMI changes. To deploy a new AMI, destroy and recreate the instance.


Security Groups

UAT — App Server

PortProtocolSourcePurpose
22TCP0.0.0.0/0SSH access
80TCP0.0.0.0/0HTTP
443TCP0.0.0.0/0HTTPS
8000TCP0.0.0.0/0FastAPI AI tools
8103TCP0.0.0.0/0Medplum API server
8080TCP0.0.0.0/0Jenkins

Production — ALB

PortProtocolSourcePurpose
80TCP0.0.0.0/0HTTP (redirected to HTTPS by ALB)
443TCP0.0.0.0/0HTTPS (terminated at ALB)

Production — App Server

PortProtocolSourcePurpose
22TCP0.0.0.0/0SSH access
80TCPALB security group onlyHTTP from ALB
8000TCP0.0.0.0/0FastAPI AI tools
8103TCP0.0.0.0/0Medplum API server
8080TCP0.0.0.0/0Jenkins
warning

On production, ports 8000, 8103, and 8080 are open directly to the internet. Only port 80 (HTTP) is restricted to ALB-only traffic. Consider locking down these ports to known IP ranges if internet-facing exposure is not required.


Application Load Balancer (Production only)

Production uses an ALB in front of the EC2 instance for HTTPS termination via ACM.

SettingValue
TypeApplication Load Balancer (internet-facing)
HTTPS certACM certificate ARN set in terraform.tfvars
TLS policyELBSecurityPolicy-TLS13-1-2-2021-06
Target group port80 (forwards to EC2 HTTP)
Health check path/healthcheck
Health check interval30s, timeout 10s
Healthy threshold2 consecutive passes
Unhealthy threshold3 consecutive fails

HTTP → HTTPS redirect: The HTTP listener (port 80) returns a 301 redirect to HTTPS — no traffic reaches the EC2 on port 80 directly.


RDS — PostgreSQL

UAT

SettingValue
EnginePostgreSQL 17.6
Instance classdb.t4g.small
Storage20 GB gp2, auto-scales to 100 GB
Storage encryptedYes
Availability zoneus-east-2b
Publicly accessibleYes (restricted by security group)
Backup retention7 days
Backup window03:00–04:00 UTC
Maintenance windowMonday 04:00–05:00 UTC
Deletion protectionDisabled
skip_final_snapshottrue
Restored from snapshotchartchat-dev-db-snapshot-18-05-2026
apply_immediatelytrue

Production

SettingValue
EnginePostgreSQL 17.6
Instance classdb.t4g.small
Storage20 GB gp2, auto-scales to 100 GB
Storage encryptedYes
Availability zoneus-east-2b
Publicly accessibleNo
Backup retention7 days
Backup window03:00–04:00 UTC
Maintenance windowMonday 04:00–05:00 UTC
Deletion protectionEnabled
skip_final_snapshotfalse (takes final snapshot on deletion)
Final snapshot identifierchartchat-prod-db-final-snapshot

RDS Security Group

  • Both: Allows port 5432 from the app server security group
  • UAT only: Also has a public rule (0.0.0.0/0 on port 5432) for direct access — should be removed after setup is complete

Credentials

RDS credentials are stored in AWS Secrets Manager at chartchat/{env}/database as a JSON object:

{
"host": "<rds-endpoint>",
"port": 5432,
"dbname": "chartchat",
"username": "postgres",
"password": "<password>"
}

The SSM parameter DatabaseSecrets stores the ARN of this secret. Medplum reads the ARN from SSM and fetches the actual credentials from Secrets Manager at startup.

Connection pool

Both environments set database.maxConnections = 100 in SSM. This is the Node.js pg pool ceiling. The pool dynamically shrinks under low load and only approaches this ceiling under burst traffic. connectionTimeoutMillis ensures requests fail fast if the pool is fully busy.


S3 — Binary Storage

SettingUATProduction
Bucket namechartchat-184995899457-us-east-2-anchartchat-prod-184995899457-us-east-2
Managed by TerraformNo — account regional namespace bucketYes
Public access blockManualAll public access blocked (all 4 flags enabled)
VersioningManualEnabled
PurposeMedplum binary/file storage (attachments, documents, images)Same

The bucket name is referenced in IAM policies and SSM parameters (binaryStorage = s3:{bucket-name}). UAT bucket settings must be managed manually via the AWS Console.

S3 versioning on the production bucket means every overwrite or delete of a binary file retains the previous version, enabling point-in-time file recovery directly from the AWS Console or CLI.


SSM Parameter Store

All Medplum server configuration is stored under /chartchat/{env}/. The server reads this on startup with the argument aws:us-east-2:/chartchat/{env}/.

Non-sensitive parameters (String)

ParameterDefault valueDescription
/chartchat/{env}/baseUrlplaceholderMedplum server base URL
/chartchat/{env}/appBaseUrlplaceholderFrontend app URL
/chartchat/{env}/port8103Server port
/chartchat/{env}/binaryStorages3:{bucket-name}S3 binary storage path
/chartchat/{env}/storageBaseUrlplaceholderPublic URL for binary storage
/chartchat/{env}/awsRegionus-east-2AWS region
/chartchat/{env}/supportEmailplaceholderSupport email address
/chartchat/{env}/adminClientIdplaceholderMedplum admin client ID
/chartchat/{env}/allowedOriginsplaceholderComma-separated allowed CORS origins
/chartchat/{env}/vmContextBotsEnabledtrueEnables VM context bots
/chartchat/{env}/defaultBotRuntimeVersionvmcontextBot runtime version
/chartchat/{env}/introspectionEnabledtrueGraphQL introspection
/chartchat/{env}/saveAuditEventstruePersist audit events to DB
/chartchat/{env}/logAuditEventstrueLog audit events
/chartchat/{env}/logRequeststrueLog all HTTP requests
/chartchat/{env}/maxJsonSize1mbMax request body size
/chartchat/{env}/maxBatchSize50mbMax batch request size
/chartchat/{env}/shutdownTimeoutMilliseconds30000Graceful shutdown timeout
/chartchat/{env}/database.maxConnections100Node.js pg connection pool ceiling
/chartchat/{env}/DatabaseSecretsARNARN of the database Secrets Manager secret
/chartchat/{env}/RedisSecretsARNARN of the Redis Secrets Manager secret

Sensitive parameters (SecureString)

ParameterDescription
/chartchat/{env}/bullBoardPasswordBull Board queue dashboard password
/chartchat/{env}/googleClientIdGoogle OAuth client ID
/chartchat/{env}/googleClientSecretGoogle OAuth client secret
/chartchat/{env}/recaptchaSiteKeyreCAPTCHA site key
/chartchat/{env}/recaptchaSecretKeyreCAPTCHA secret key
/chartchat/{env}/smtpSMTP config JSON {"host":"...","port":465,"username":"...","password":"..."}
note

Terraform creates all SSM parameters with "placeholder" values and lifecycle.ignore_changes = [value]. After terraform apply, fill the real values manually in the AWS Console. Terraform will never overwrite them.


Secrets Manager

Secret nameContentsUsed by
chartchat/{env}/databaseRDS credentials JSONMedplum server via SSM ARN pointer
chartchat/{env}/redisRedis credentials JSON {"host":"localhost","port":6379,"password":"..."}Medplum server via SSM ARN pointer
chartchat/{env}/app-secretsAll application env vars as a single JSON object (EHR credentials, Medplum client config, API keys, redirect URIs, etc.)App server at startup

Populate all three manually in the AWS Console after terraform apply.


IAM

EC2 Instance Role — chartchat-{env}-ec2-role

Attached policies:

PolicyPermissions
chartchat-{env}-s3-full-accesss3:ListBucket, s3:GetObject, s3:PutObject, s3:DeleteObject on the S3 bucket
chartchat-{env}-secrets-managerSSM GetParameter* on /chartchat/{env}/*, KMS decrypt for SecureStrings, Secrets Manager GetSecretValue for DB, Redis, and app-secrets
CloudWatchAgentServerPolicyAWS managed policy — allows the CloudWatch Agent to publish memory and disk metrics

DLM Role — chartchat-{env}-dlm-role

Uses the AWS managed policy AWSDataLifecycleManagerServiceRoleForAMIManagement to allow DLM to take AMI snapshots.


DLM — Daily AMI Snapshots

Automatically creates a daily AMI of the app server EC2 instance.

SettingValue
ScheduleDaily at 03:00 UTC
RetentionLast 7 AMIs
TargetEC2 instances tagged Name = chartchat-{env}-server

This means at any time you have 7 daily restore points for the full server (OS, nginx config, app code, Jenkins config).


CloudWatch

Both environments have a CloudWatch dashboard and SNS alert topic. Alerts are emailed to the address in alert_email in terraform.tfvars.

Alarms

AlarmThresholdUATProd
RDS connections high> 150 (75% of max 200)YesYes
RDS CPU high> 80%YesYes
RDS freeable memory low< 100 MB (UAT) / < 200 MB (Prod)YesYes
RDS free storage low< 2 GBYesYes
App server CPU high> 80%YesYes
App server status check failedAny failureYesYes
App server memory high> 85%YesYes
App server disk high> 80% on /YesYes
LLM server CPU high> 90%NoYes
LLM server status check failedAny failureNoYes
ALB 5XX errors high> 10 per minuteNoYes
ALB unhealthy hosts> 0NoYes

Memory and disk alarms require the CloudWatch Agent to be installed and running on the EC2 instance (pushed via SSM Run Command). Without it, those alarms will have no data.


LLM Server (AI Tools)

The LLM server is a GPU EC2 instance (g5.xlarge) shared between UAT and Production. It runs the FastAPI AI tools service and is managed in the aws/shared/ Terraform workspace — completely independent of the UAT and Production workspaces.

SettingValue
Instance IDi-06e93d4e5833e8c7e
Instance typeg5.xlarge (GPU instance — 1× NVIDIA A10G)
AMIami-072159ab3176afd71 (chartchat-llm-ami-2026-06-08)
Key pairChartChat-AI-Server
Subnetsubnet-024cccfad278a3557
Security groupsg-0e3f74e9e584fd681 (managed manually — not in Terraform)
Elastic IP allocationeipalloc-07d05d765d1a2881f
Root volume100 GB gp3, delete_on_termination = false
IAM roleNone assigned
Terraform state keyshared/terraform.tfstate
ignore_changesami, root_block_device, user_data, tags — manage manually

Root volume retention

delete_on_termination = false means if the EC2 instance is ever terminated, the root EBS volume is not deleted automatically. This preserves the model weights and AI tooling data on the volume even if the instance is accidentally terminated.

Elastic IP

The LLM server has a permanent Elastic IP (eipalloc-07d05d765d1a2881f) associated via Terraform. The EIP itself has ignore_changes = [instance] — Terraform manages the association resource separately so the EIP is never accidentally re-assigned.

Importing into Terraform state

If the Terraform state is ever lost, re-import with:

cd aws/shared
terraform import aws_instance.llm_server i-06e93d4e5833e8c7e
terraform import aws_eip.llm_server eipalloc-07d05d765d1a2881f
warning

The LLM instance is managed in aws/shared/ — do not import or recreate it from the uat/ or prod/ workspaces. The environment-level llm-server.tf files are commented out intentionally.


Terraform Workflow

First-time setup

cd aws/uat   # or aws/prod
cp terraform.tfvars.example terraform.tfvars
# Fill in terraform.tfvars with real values
terraform init
terraform plan
terraform apply

Outputs

After terraform apply:

OutputDescriptionUATProd
server_public_ipElastic IP of the app serverYesYes
server_instance_idEC2 instance IDYesYes
rds_endpointRDS connection endpointYesYes
alb_dns_nameALB DNS name — point domain CNAME hereNoYes

After applying — manual steps

  1. Fill real values into all SSM parameters (replace "placeholder")
  2. Populate all three Secrets Manager secrets: database, redis, app-secrets
  3. Remove the temporary public RDS rule in UAT once direct DB access is no longer needed

Environment Differences Summary

ResourceUATProduction
ALBNoYes
HTTPS terminationnginx + CertbotALB + ACM
RDS deletion protectionDisabledEnabled
RDS publicly accessibleYes (+ public SG rule)No
RDS final snapshot on deleteNoYes
S3 bucketchartchat-184995899457-us-east-2-an (manual)chartchat-prod-184995899457-us-east-2 (Terraform)
LLM server alarmsNoYes
ALB alarmsNoYes
RDS memory alarm threshold100 MB200 MB