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 /health with a 200 so App Runner knows the container is alive, then forwards any POST /agent calls 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-runtime is the official AWS SDK for the Claude model.
  • aws-xray-sdk adds distributed tracing without extra code; App Runner can surface those traces in the console.

Tip: Keep the /health endpoint 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 8080 is a hint; App Runner actually injects the port via PORT env 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-repo exists; 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 /health to 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 /health to 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:

  1. 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.
  2. Turn on tracing – The ObservabilityEnabled: true flag 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 /agent handler → external fetch → Bedrock → response.

Tip: If you see “segment missing” warnings, double‑check that the SDK client is wrapped (the captureAWSv3Client line).

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-ecr and @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.log output, 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.