Running an AI agent usually means wrestling with EC2, Fargate, or complex Kubernetes setups. AWS App Runner lets you skip that boilerplate and get a secure, auto‑scaled container live in minutes. This guide shows exactly how to turn a Node.js Claude agent into a production service without managing servers.
Why App Runner Beats ECS for Quick AI Agent Deploys
Why it matters – When you just want an endpoint that talks to Claude, the overhead of provisioning clusters, writing task definitions, and wiring load balancers can eclipse the actual logic of your agent. Amazon Elastic Container Service (ECS) is great for large fleets, but for a single‑container AI service it adds a lot of ceremony.
App Runner in plain English – Think of App Runner as a “restaurant host” that takes a ready‑made dish (your Docker image), finds a table (a compute instance), serves it to customers, and automatically cleans up when nobody is eating. You don’t need to hire chefs, bussers, or manage the kitchen yourself.
Key benefits for AI agents
- Zero‑config networking – App Runner creates a public HTTPS URL for you; no VPC or load‑balancer fiddling.
- Built‑in auto‑scaling – It adds or removes compute based on request rate, so your Claude calls stay responsive under load.
- Integrated observability – Logs go straight to CloudWatch; you can turn on X‑Ray tracing with a single toggle.
Takeaway: If your goal is “run a Claude‑backed endpoint fast,” App Runner removes the heavy lifting that ECS and Kubernetes demand.
Setting Up Your Claude Agent Codebase with Native ES Modules
Why start with a clean codebase? – A minimal project keeps the focus on the deployment steps instead of wrestling with build tooling. Node.js 22 supports native ECMAScript (ES) modules out of the box, so we can write modern import statements without a bundler.
1. Project layout
claude-agent/
├─ src/
│ ├─ index.ts # entry point, defines HTTP routes
│ └─ claude.ts # thin wrapper around Bedrock's chat API
├─ Dockerfile
├─ package.json
└─ tsconfig.json
2. package.json (only the essentials)
{
"name": "claude-agent",
"version": "1.0.0",
"type": "module", // tells Node to treat .js/.ts as ES modules
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"aws-sdk-client-bedrock-runtime": "^3.600.0",
"aws-xray-sdk": "^3.5.0"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
type: "module" makes every file an ES module, so we can use import … from ….
3. TypeScript configuration (tsconfig.json)
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"]
}
4. The HTTP server (src/index.ts)
// src/index.ts
import http from "node:http"; // built‑in Node HTTP server
import { handleAgent } from "./claude.js"; // our Claude wrapper
import { URL } from "node:url";
// Simple health check – App Runner expects a 200 on '/health'
function healthHandler(req: http.IncomingMessage, res: http.ServerResponse) {
if (req.method === "GET" && new URL(req.url || "", `http://${req.headers.host}`).pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return true;
}
return false;
}
// Main request router
const server = http.createServer(async (req, res) => {
// First, satisfy App Runner's health probe
if (healthHandler(req, res)) return;
const url = new URL(req.url || "", `http://${req.headers.host}`);
if (req.method === "POST" && url.pathname === "/agent") {
await handleAgent(req, res); // delegated to the Claude helper
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
}
});
const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
server.listen(PORT, () => {
console.log(`🚀 Claude agent listening on http://localhost:${PORT}`);
});
Plain English: The server listens on the port App Runner tells it (
process.env.PORT). It first answers/healthwith a 200 so App Runner knows the container is alive, then forwards anyPOST /agentcalls to our Claude helper.
5. Claude wrapper (src/claude.ts)
// src/claude.ts
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import { captureAWSv3Client } from "aws-xray-sdk";
import { IncomingMessage, ServerResponse } from "node:http";
// Wrap the Bedrock client with X‑Ray for tracing (optional but cheap)
const bedrock = captureAWSv3Client(new BedrockRuntimeClient({ region: "us-east-1" }));
// Helper to collect the request body as a string
async function getRequestBody(req: IncomingMessage): Promise<string> {
const chunks: Uint8Array[] = [];
for await (const chunk of req) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf-8");
}
// Core logic: fetch extra info, call Claude, combine results
export async function handleAgent(req: IncomingMessage, res: ServerResponse) {
try {
// 1️⃣ Read user prompt from request body (expects JSON { "prompt": "..." })
const body = await getRequestBody(req);
const { prompt } = JSON.parse(body);
// 2️⃣ Pull auxiliary data from a fake external API
const infoResponse = await fetch("https://api.example.com/info");
const infoJson = await infoResponse.json(); // { "detail": "...", "metadata": { … } }
// 3️⃣ Build the payload for Claude (Bedrock's chat/completions API)
const payload = {
modelId: "anthropic.claude-3-5-sonnet-20241022-v1:0", // Claude model identifier
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: `${prompt}\nAdditional info: ${JSON.stringify(infoJson)}` }
],
maxTokens: 1024,
temperature: 0.7
};
// 4️⃣ Invoke the model
const command = new InvokeModelCommand({
body: JSON.stringify(payload),
contentType: "application/json",
accept: "application/json",
modelId: payload.modelId
});
const bedrockResponse = await bedrock.send(command);
const modelOutput = JSON.parse(Buffer.from(bedrockResponse.body).toString());
// 5️⃣ Return combined result to the caller
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
prompt,
externalInfo: infoJson,
claudeResponse: modelOutput
}));
} catch (err) {
console.error("❗️ Agent error:", err);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
Key points
- We use native fetch (available in Node 22) to call
https://api.example.com/info. -
@aws-sdk/client-bedrock-runtimeis the official AWS SDK for the Claude model. -
aws-xray-sdkadds distributed tracing without extra code; App Runner can surface those traces in the console.
Tip: Keep the
/healthendpoint separate from your business logic. App Runner’s health‑check runs every few seconds; a cheap 200 response avoids unnecessary Claude calls.
Containerizing the Agent and Pushing to ECR
Why containerize? – A Docker image captures the exact runtime (Node 22, dependencies, OS libraries). That image can run anywhere—locally for testing, or in the cloud via App Runner.
1. Dockerfile
# Dockerfile
# Use the official Node 22 LTS image as a base (lightweight Alpine variant)
FROM node:22-alpine AS builder
# Install build tools for TypeScript compilation
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Copy source files and compile TypeScript
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Runtime stage – only the compiled JS and runtime deps
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/package.json .
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Expose the port that App Runner will map (default 8080, but we honor $PORT)
EXPOSE 8080
ENV NODE_ENV=production
# Start the service
CMD ["node", "dist/index.js"]
Explanation
- Multi‑stage build – The first stage compiles TypeScript, the second stage ships only what’s needed to run, keeping the final image small (< 50 MB).
-
EXPOSE 8080is a hint; App Runner actually injects the port viaPORTenv var, which our code respects.
2. Create an Amazon ECR repository
// ecr-setup.ts
import {
ECRClient,
CreateRepositoryCommand,
DescribeRepositoriesCommand
} from "@aws-sdk/client-ecr";
const ecr = new ECRClient({ region: "us-east-1" });
async function ensureRepo(repoName: string) {
// Check if repo already exists
const describe = new DescribeRepositoriesCommand({ repositoryNames: [repoName] });
try {
await ecr.send(describe);
console.log(`📦 Repository "${repoName}" already exists`);
} catch {
// Repository not found – create it
const create = new CreateRepositoryCommand({
repositoryName: repoName,
imageScanningConfiguration: { scanOnPush: true },
tags: [{ key: "project", value: "claude-agent" }]
});
await ecr.send(create);
console.log(`✅ Created repository "${repoName}"`);
}
}
// Run the helper
ensureRepo("claude-agent-repo").catch(console.error);
Plain English: The script asks ECR if a repo called
claude-agent-repoexists; if not, it creates one.
3. Build, tag, and push the image
# 1️⃣ Authenticate Docker to your ECR registry (the CLI prints a login command)
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com
# 2️⃣ Build the Docker image locally
docker build -t claude-agent:latest .
# 3️⃣ Tag the image with the full ECR URI
docker tag claude-agent:latest <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/claude-agent-repo:latest
# 4️⃣ Push to ECR
docker push <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/claude-agent-repo:latest
Gotcha: App Runner’s cold start (the time it takes to spin up a fresh container) can be 10‑30 seconds. For latency‑sensitive APIs you might need a warm pool or a different service, but for many internal tools that latency is acceptable.
Tip: Keep the image size small; every extra megabyte adds to cold‑start time. Multi‑stage builds, as shown above, are the easiest way to stay lean.
Creating an App Runner Service Linked to Your Claude API
Why use the SDK instead of the console? – Automating the service creation makes the process repeatable, version‑controlled, and easy to integrate into CI/CD pipelines.
1. Minimal App Runner creation script
// apprunner-create.ts
import {
AppRunnerClient,
CreateServiceCommand,
CreateServiceCommandInput
} from "@aws-sdk/client-apprunner";
const client = new AppRunnerClient({ region: "us-east-1" });
async function createClaudeService() {
// Service definition – tells App Runner where to find the container image
const params: CreateServiceCommandInput = {
ServiceName: "claude-agent-service",
SourceConfiguration: {
ImageRepository: {
ImageIdentifier: "<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/claude-agent-repo:latest",
ImageRepositoryType: "ECR",
// Optional: If you have a private repo, give App Runner a role that can read it
},
AuthenticationConfiguration: {
// If the ECR repo is private, App Runner needs an IAM role. Omit for public repos.
}
},
InstanceConfiguration: {
Cpu: "1vCPU",
Memory: "2GB"
},
HealthCheckConfiguration: {
// App Runner expects a 200 on '/' by default. We override it to our '/health' endpoint.
Path: "/health",
Protocol: "HTTP",
Interval: 10,
Timeout: 5,
HealthyThreshold: 1,
UnhealthyThreshold: 5
},
AutoScalingConfigurationArn: undefined, // use default auto‑scaling
// Enable observability features
ObservabilityConfiguration: {
ObservabilityEnabled: true // turns on CloudWatch Logs and X‑Ray
}
};
try {
const command = new CreateServiceCommand(params);
const response = await client.send(command);
console.log("🚀 App Runner service created:");
console.log(JSON.stringify(response.Service, null, 2));
} catch (err) {
console.error("❌ Failed to create service:", err);
}
}
// Execute
createClaudeService().catch(console.error);
Key concepts
-
ImageIdentifier– The full ECR URI of the image we pushed earlier. -
HealthCheckConfiguration.Path– We point it at/healthto avoid the silent‑deploy problem where App Runner keeps waiting for a 200 on/. -
ObservabilityEnabled– Turns on CloudWatch Logs and X‑Ray without extra steps.
In plain English: The script tells App Runner, “run this container, check
/healthto see if it’s alive, and send all logs and traces to AWS so I can look at them later.”
2. Deploying the service
Run the script with ts-node (or compile it first):
npx ts-node apprunner-create.ts
After a minute or two you’ll see an output that includes a ServiceUrl. Visit that URL with a tool like curl:
curl -X POST -H "Content-Type: application/json" \
-d '{"prompt":"What is the weather in Paris?"}' \
https://<service-id>.awsapprunner.com/agent
You should receive a JSON payload that merges the external API data with Claude’s answer.
Gotcha: If you forget to set the health‑check path to
/health, App Runner will mark the deployment as Pending forever because it never receives a 200 response.
Observability: Hooking CloudWatch Logs and X‑Ray
Why observe? – AI agents can fail silently (e.g., Claude throttling, external API downtime). Having logs and traces lets you spot those problems fast.
1. CloudWatch Logs
App Runner automatically streams stdout and stderr to a CloudWatch log group named /aws/apprunner/<service-name>. The console.log statements we added in index.ts and claude.ts will appear there.
Quick test
aws logs tail /aws/apprunner/claude-agent-service --follow
You’ll see messages like 🚀 Claude agent listening on http://localhost:8080 and any error stack traces.
2. Enabling X‑Ray Tracing
We already wrapped the Bedrock client with captureAWSv3Client. To make the trace data visible:
-
Add the X‑Ray daemon permission – In the IAM role that App Runner uses (created automatically if you let the console generate one), attach the policy
AWSXRayDaemonWriteAccess. -
Turn on tracing – The
ObservabilityEnabled: trueflag in the creation script activates X‑Ray for the service.
Now go to the AWS X‑Ray console, choose Service map, and you’ll see a node for claude-agent-service. Clicking it shows the request flow:
- API Gateway (App Runner) → your
/agenthandler → externalfetch→ Bedrock → response.
Tip: If you see “segment missing” warnings, double‑check that the SDK client is wrapped (the
captureAWSv3Clientline).
3. Adding custom annotations
You can enrich traces with your own metadata. For example, add a request ID:
import { Segment, subsegment } from "aws-xray-sdk";
export async function handleAgent(req: IncomingMessage, res: ServerResponse) {
const segment = Segment.fromXRayTraceId(); // gets current trace segment
const requestId = crypto.randomUUID();
segment.addAnnotation("requestId", requestId);
// ... rest of the logic
}
Now every trace includes the requestId annotation, making it easy to correlate logs and traces.
The Takeaway
You’ve just turned a tiny Claude‑powered Node.js app into a fully managed, auto‑scaled web service using AWS App Runner.
- App Runner eliminates the need for clusters – no EC2, Fargate, or Kubernetes configuration required.
-
A health endpoint (
/health) is essential – without it, App Runner’s default check on “/” will keep the deployment pending. - Docker multi‑stage builds keep images small, reducing cold‑start latency.
-
The AWS SDKs (
@aws-sdk/client-ecrand@aws-sdk/client-apprunner) let you script repository creation and service deployment, making the workflow reproducible. -
Observability comes out of the box – CloudWatch logs capture
console.logoutput, and X‑Ray tracing reveals the end‑to‑end request path. - Remember the limits – App Runner cold starts can be 10‑30 seconds and there is no persistent storage, so keep state external (e.g., DynamoDB) if you need it.
Now you can iterate on your Claude prompts, add more endpoints, or spin up additional agents—all with a few commands and no server‑maintenance headaches. Happy building!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-11-09 · Primary focus: AppRunner
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)