Building a Multi-Agent AWS Platform with Amazon Bedrock AgentCore — Part 9: Deploying to AgentCore Runtime
In Part 9 we deploy all three agents as AgentCore Runtime containers, the FastAPI backend on ECS Fargate behind CloudFront, and the React frontend to S3. You'll learn the Dockerfile pattern, Runtime YAML configs, CDK stacks, async job polling for long-running requests, and the four-layer security model
Deployment overview
The platform has three deployment targets:
- AgentCore Runtime — the three agents (Orchestrator, Diagram, Terraform) run as managed serverless containers
- ECS Fargate — the FastAPI backend runs as a long-lived container behind an ALB
- CloudFront + S3 — the React SPA is served from S3 via CloudFront
1
2
3
4
5
6
7
Internet
└── CloudFront (WAF + secret header)
├── S3 (React SPA — static assets)
└── ALB (API calls — restricted to CloudFront prefix list)
└── ECS Fargate (FastAPI backend)
└── AgentCore Runtime (3 agent containers)
└── Amazon Bedrock (Claude Sonnet)AgentCore Runtime: the Dockerfile
AgentCore Runtime requires a specific Dockerfile pattern — uv base image, non-root user, HTTP server on port 8080:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# backend/Dockerfile
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Non-root user required by AgentCore Runtime
RUN useradd -m -u 1000 appuser
WORKDIR /app
# Copy dependency files first (layer caching)
COPY pyproject.toml ./
RUN uv sync --no-dev
# Copy application code
COPY backend/ ./backend/
# Switch to non-root user
USER appuser
# AgentCore Runtime expects HTTP on port 8080
EXPOSE 8080
# Start the FastAPI app with uvicorn
CMD ["uv", "run", "uvicorn", "backend.api.main:app",
"--host", "0.0.0.0", "--port", "8080", "--workers", "1"]The
--workers 1 is intentional. AgentCore Runtime handles horizontal scaling at the container level — multiple single-worker containers rather than one multi-worker container. This gives cleaner isolation and simpler state management.AgentCore Runtime: YAML configs
Each agent has a YAML config in
infra/runtime/:1
2
3
4
5
6
7
8
9
10
11
12
# infra/runtime/orchestrator.yaml
agent: orchestrator
framework: langgraph
runtime: agentcore
min_instances: 1
max_instances: 20
timeout_seconds: 120
memory_mb: 1024
environment:
AWS_DEFAULT_REGION: us-east-1
LOG_LEVEL: INFO
entry_point: backend.agents.orchestrator_agent:OrchestratorAgent1
2
3
4
5
6
7
8
9
# infra/runtime/diagram_agent.yaml
agent: diagram
framework: strands
runtime: agentcore
min_instances: 1
max_instances: 20
timeout_seconds: 120
memory_mb: 1024
entry_point: backend.agents.diagram_agent:DiagramAgent1
2
3
4
5
6
7
8
9
10
# infra/runtime/scaling_policy.yaml
scaling:
metric: concurrent_requests
scale_out_threshold: 0.8 # scale out when 80% capacity used
scale_in_threshold: 0.2 # scale in when below 20%
cooldown_seconds: 60
restart_policy:
on_failure: restart # transparent restart, no user-visible failure
max_restarts_per_hour: 10Deploying the agents
We use a Python deploy script that calls the AgentCore control plane API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# infra/runtime/deploy.py
import argparse
import boto3
import yaml
from pathlib import Path
from backend.aws_session import get_session
AGENTS = ["orchestrator", "diagram", "terraform"]
def deploy_agent(agent_name: str, region: str = "us-east-1") -> None:
"""Deploy or update an AgentCore Runtime agent."""
config_path = Path(f"infra/runtime/{agent_name}.yaml")
config = yaml.safe_load(config_path.read_text())
session = get_session()
client = session.client("bedrock-agentcore-control", region_name=region)
# Check if agent already exists
try:
existing = client.get_agent_runtime(
agentRuntimeName=f"agentcore_demo_{agent_name}"
)
agent_runtime_id = existing["agentRuntimeId"]
print(f"Updating existing agent: {agent_runtime_id}")
client.update_agent_runtime(
agentRuntimeId=agent_runtime_id,
description=f"AgentCore Demo {agent_name.title()} Agent",
agentRuntimeArtifact={
"containerConfiguration": {
"containerUri": get_ecr_uri(agent_name, region),
}
},
)
except client.exceptions.ResourceNotFoundException:
print(f"Creating new agent: agentcore_demo_{agent_name}")
response = client.create_agent_runtime(
agentRuntimeName=f"agentcore_demo_{agent_name}",
description=f"AgentCore Demo {agent_name.title()} Agent",
agentRuntimeArtifact={
"containerConfiguration": {
"containerUri": get_ecr_uri(agent_name, region),
}
},
networkConfiguration={
"networkMode": "PUBLIC"
},
protocolConfiguration={
"serverProtocol": "HTTP"
},
)
agent_runtime_id = response["agentRuntimeId"]
print(f"Created: {agent_runtime_id}")
print(f"✓ {agent_name} deployed")
def get_ecr_uri(agent_name: str, region: str) -> str:
"""Get the ECR image URI for an agent."""
session = get_session()
account_id = session.client("sts").get_caller_identity()["Account"]
return (
f"{account_id}.dkr.ecr.{region}.amazonaws.com/"
f"agentcore-demo-{agent_name}:latest"
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--agent", choices=AGENTS + ["all"], default="all")
parser.add_argument("--region", default="us-east-1")
args = parser.parse_args()
agents = AGENTS if args.agent == "all" else [args.agent]
for agent in agents:
deploy_agent(agent, args.region)Deploy all three agents:
1
python3 infra/runtime/deploy.py --agent allCDK: the platform stack
The ECS backend, ALB, CloudFront, DynamoDB, and S3 are all deployed with CDK. Here is the key structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// infra/platform/src/backend-stack.ts (simplified)
import * as cdk from 'aws-cdk-lib';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
export class AgentCoreBackendStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props: cdk.StackProps) {
super(scope, id, props);
// ECS Fargate service
const taskDef = new ecs.FargateTaskDefinition(this, 'BackendTask', {
cpu: 1024, // 1 vCPU
memoryLimitMiB: 2048,
});
taskDef.addContainer('BackendContainer', {
image: ecs.ContainerImage.fromEcrRepository(backendRepo, 'latest'),
portMappings: [{ containerPort: 8080 }],
environment: {
AGENTCORE_MEMORY_ENABLED: 'true',
AGENTCORE_MEMORY_ID: memoryId,
AGENTCORE_RUNTIME_MODE: 'true',
SESSIONS_TABLE: sessionsTable.tableName,
ARTIFACT_BUCKET: artifactsBucket.bucketName,
},
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'agentcore-demo' }),
});
const service = new ecs.FargateService(this, 'BackendService', {
cluster,
taskDefinition: taskDef,
desiredCount: 1,
});
// Auto-scaling: 1 → 10 instances
const scaling = service.autoScaleTaskCount({ maxCapacity: 10 });
scaling.scaleOnCpuUtilization('CpuScaling', {
targetUtilizationPercent: 70,
});
// ALB — restricted to CloudFront prefix list only
const alb = new elbv2.ApplicationLoadBalancer(this, 'ALB', {
vpc,
internetFacing: true,
});
// CloudFront secret header — ALB rejects requests without it
const cfSecret = new secretsmanager.Secret(this, 'CFSecret');
listener.addAction('Default', {
action: elbv2.ListenerAction.fixedResponse(403, {
contentType: 'text/plain',
messageBody: 'Forbidden',
}),
});
listener.addAction('WithSecret', {
priority: 1,
conditions: [
elbv2.ListenerCondition.httpHeader(
'X-CF-Origin-Secret', [cfSecret.secretValue.unsafeUnwrap()]
),
],
action: elbv2.ListenerAction.forward([targetGroup]),
});
}
}Async job polling for long-running requests
Terraform generation takes 60–130 seconds. CloudFront has a 60-second timeout. We solve this with async job polling:
- The frontend sends a message request
- The backend starts the job and waits up to 55 seconds
- If the job completes in time, return the result directly
- If not, return a
jobIdand the frontend pollsGET /sessions/{id}/jobs/{jobId}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# backend/api/main.py
async def send_message(sessionId: str, message: str = Form(...)) -> dict:
session_data = _session_get(sessionId)
user_id = session_data["userId"]
job_id = str(uuid.uuid4())
_job_put(job_id, {"status": "pending", "result": None, "error": None})
async def _run_job():
try:
orchestrator = get_orchestrator()
result = await orchestrator.route_to_agents({
"message": message,
"session_id": sessionId,
"user_id": user_id,
"context": {},
})
_job_put(job_id, {"status": "done", "result": result, "error": None})
except Exception as exc:
_job_put(job_id, {"status": "error", "result": None, "error": str(exc)})
asyncio.create_task(_run_job())
# Wait up to 55 seconds for quick completion (under CloudFront's 60s limit)
for _ in range(55):
await asyncio.sleep(1)
job = _job_get(job_id)
if job and job["status"] != "pending":
break
job = _job_get(job_id)
if job and job["status"] == "done":
_job_delete(job_id)
return job["result"]
elif job and job["status"] == "error":
_job_delete(job_id)
raise PlatformException(code=AGENT_ERROR, message=job["error"])
else:
# Still running — return jobId for polling
return {"jobId": job_id, "status": "pending"}
async def get_job(sessionId: str, jobId: str) -> dict:
"""Poll for async job result."""
job = _job_get(jobId)
if not job:
raise PlatformException(code="JOB_NOT_FOUND", message=f"Job '{jobId}' not found.")
if job["status"] == "done":
return {"status": "done", "result": job["result"]}
return {"status": job["status"], "error": job.get("error")}Jobs are stored in DynamoDB with a 1-hour TTL so they survive ECS task restarts and are accessible across multiple ECS workers.
The four-layer security model
The platform uses four independent security layers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Layer 1: ALB restricted to CloudFront prefix list (pl-3b927c52)
→ Blocks direct access to the ALB from the internet
Layer 2: WAF on CloudFront
→ Blocks SQLi, XSS, phpunit scanners
→ Rate limits: 500 requests per 5 minutes per IP
Layer 3: CloudFront secret header (X-CF-Origin-Secret)
→ ALB rejects any request without the correct header value
→ Header value stored in Secrets Manager
Layer 4: API key authentication (X-API-Key header)
→ FastAPI middleware validates the key on every non-public endpoint
→ Key stored in Secrets Manager, baked into the frontend buildThe WAF has a special allow rule for API action endpoints (validate, security-scan, estimate-cost) that bypasses body inspection — necessary because HCL/Terraform code in request bodies triggers false-positive WAF rules.
Deploying
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 1. Deploy prerequisites (ECR repos, IAM roles, S3 buckets)
cd infra/prerequisites
cdk deploy --profile redact
# 2. Build and push Docker images
aws ecr get-login-password --profile redact | docker login --username AWS \
--password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
docker build -t agentcore-demo-backend backend/
docker tag agentcore-demo-backend:latest \
123456789012.dkr.ecr.us-east-1.amazonaws.com/agentcore-demo-backend:latest
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/agentcore-demo-backend:latest
# 3. Deploy platform stacks (ECS, ALB, CloudFront, DynamoDB)
cd infra/platform
cdk deploy --all --profile redact
# 4. Deploy AgentCore Runtime agents
python3 infra/runtime/deploy.py --agent all
# 5. Build and deploy frontend
cd frontend
npm run build
./infra/platform/deploy-frontend.shEnvironment variables reference
| Variable | Description | Default |
|---|---|---|
AGENTCORE_MEMORY_ENABLED | Enable real AgentCore Memory | false |
AGENTCORE_MEMORY_ID | AgentCore Memory resource ID | — |
AGENTCORE_RUNTIME_MODE | Route to Runtime containers | false |
AGENTCORE_BROWSER_ID | AgentCore Browser Tool ID | — |
AGENTCORE_CODE_INTERPRETER_ENABLED | Enable AgentCore sandbox | true |
SESSIONS_TABLE | DynamoDB table for sessions/jobs | — |
ARTIFACT_BUCKET | S3 bucket for uploaded files | — |
OTLP_ENDPOINT | OTLP collector endpoint for X-Ray | — |
API_KEY | API key for request authentication | — |
What's next
In Part 10 we do a complete end-to-end walkthrough — from a user typing a prompt in the browser, through every layer of the system, all the way to a rendered diagram and validated Terraform module appearing on screen.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article