You can spin up a production‑grade RAG service without wrestling with EC2, Fargate, or complex Terraform. In a few minutes you’ll have a Node.js 22 API that pulls relevant chunks from DynamoDB vector search and calls Claude for answer generation, all running on App Runner’s fully managed platform.
Why App Runner Is Ideal for AI‑Powered APIs
In plain English: App Runner gives you a “run‑your‑code” button that hides servers, load balancers, and scaling rules behind a single service definition.
The problem most engineers face
When you start building a Retrieval‑Augmented Generation (RAG) service you need three things:
-
Fast, private access to a vector store – we use DynamoDB with the new
vectorSearchoperation. - A place to keep the raw documents – S3 is the cheap, durable bucket for that.
- A model endpoint – Anthropic’s Claude is called over HTTPS.
Typical choices are ECS (Elastic Container Service) or Lambda. Both work, but they also require you to:
- Write task definitions or function packaging.
- Wire VPC networking manually.
- Think about cold starts (Lambda) or long‑running containers (ECS).
App Runner sits in the middle: you provide a container image, tell it which VPC to use, and it takes care of health checks, HTTPS, and auto‑scaling. For a service that does a quick DynamoDB lookup and a remote HTTP call, the latency added by App Runner’s cold start (10‑30 s) is usually acceptable if you keep the container warm with a health‑check ping.
Core benefits
| Benefit | What it means for a RAG API |
|---|---|
| Fully managed HTTPS | No need to configure a load balancer or ACM certificate. |
| VPC connector | Private traffic to DynamoDB and S3 stays inside your network, avoiding public internet exposure. |
| Automatic scaling | Instances grow and shrink based on request count, keeping cost low when traffic is idle. |
| Integrated observability | Built‑in CloudWatch logs and metrics without extra agents. |
Key takeaway: App Runner removes the operational plumbing, letting you focus on the retrieval and generation logic.
Setting Up the App Runner Service with a VPC Connector
Before we write any code, we need a place where that code will run.
Step 1 – Create a VPC connector
A VPC connector is a private tunnel that lets an App Runner service reach resources inside a Virtual Private Cloud (VPC). Think of it as a secure hallway that only your service can walk through to get to DynamoDB and S3.
// createVpcConnector.ts
import {
AppRunnerClient,
CreateVpcConnectorCommand,
} from "@aws-sdk/client-apprunner";
const client = new AppRunnerClient({ region: "us-east-1" });
async function createConnector() {
const command = new CreateVpcConnectorCommand({
VpcConnectorName: "rag-app-runner-connector",
Subnets: [
"subnet-0abc123def456ghi", // private subnet A
"subnet-0jkl789mno012pqr", // private subnet B
],
SecurityGroups: ["sg-0examplesecuritygroup"], // will need outbound HTTPS
});
const response = await client.send(command);
console.log("Connector ARN:", response.VpcConnector?.VpcConnectorArn);
}
createConnector().catch(console.error);
Why this matters: The connector tells App Runner which subnets and security groups to use. The security group must allow outbound HTTPS (port 443) to the DynamoDB VPC endpoint; otherwise you’ll see AccessDeniedException errors that look like credential problems.
Tip: After creating the connector, add an outbound rule
HTTPS (443) → 0.0.0.0/0or, tighter, target the DynamoDB endpoint IPs.
Step 2 – Build a minimal Docker image
App Runner runs containers, so we package our Express API in a Dockerfile.
# Dockerfile
FROM node:22-alpine AS builder
# Install only production dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
# Copy source files
COPY src ./src
# Use a non‑root user for safety
RUN addgroup -S app && adduser -S runner -G app
USER runner
# Expose the port App Runner expects (default 8080)
EXPOSE 8080
# Start the server
CMD ["node", "src/index.js"]
Why this matters: The --production flag keeps the image small, which reduces start‑up time. Using a non‑root user follows best‑practice security.
Step 3 – Deploy the service
Now we tie everything together with the App Runner SDK.
// deployAppRunner.ts
import {
AppRunnerClient,
CreateServiceCommand,
} from "@aws-sdk/client-apprunner";
const client = new AppRunnerClient({ region: "us-east-1" });
async function deploy() {
const command = new CreateServiceCommand({
ServiceName: "rag-api",
SourceConfiguration: {
ImageRepository: {
ImageIdentifier: "123456789012.dkr.ecr.us-east-1.amazonaws.com/rag-api:latest",
ImageRepositoryType: "ECR",
// App Runner pulls the image automatically when you push a new tag
},
AuthenticationConfiguration: {
// If the repository is private, provide an IAM role or secret
},
},
InstanceConfiguration: {
Cpu: "1 vCPU",
Memory: "2 GB",
},
HealthCheckConfiguration: {
// App Runner expects a 200‑OK response on this path
Path: "/health",
Protocol: "TCP",
Interval: 10,
Timeout: 5,
HealthyThreshold: 1,
UnhealthyThreshold: 5,
},
NetworkConfiguration: {
EgressConfiguration: {
EgressType: "VPC",
VpcConnectorArn:
"arn:aws:apprunner:us-east-1:123456789012:vpcconnector/rag-app-runner-connector",
},
},
});
const response = await client.send(command);
console.log("Service URL:", response.Service?.ServiceUrl);
}
deploy().catch(console.error);
Why this matters: The NetworkConfiguration tells App Runner to use the VPC connector we created, making DynamoDB and S3 reachable without exposing them to the internet. The health‑check ensures the service only receives traffic when the container is ready; misconfiguring it (e.g., pointing at a non‑existent route) leads to silent deploy failures.
Gotcha: App Runner cold starts can take 10‑30 seconds. If you need sub‑second latency for every request, consider keeping the service warm with a periodic health‑check ping or explore ECS/Fargate.
Building the RAG Endpoint: S3 Documents → DynamoDB Vector Search → Claude Completion
Now that the platform is ready, let’s write the actual API logic.
Overview of the flow
-
Client sends a question to
POST /rag. -
We query DynamoDB with the
vectorSearchAPI to get the IDs of the most similar document chunks. -
For each ID we fetch the raw text from S3 (the documents are stored as plain
.txtfiles). - We assemble a prompt that includes the retrieved chunks and the user question.
- We call Claude (Anthropic’s LLM) over HTTPS to generate an answer.
- We return JSON containing the answer and the IDs of the chunks we used.
The Express handler
// src/ragHandler.ts
import express from "express";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
import fetch from "node-fetch";
// Claude endpoint – replace with your own API key
const CLAUDE_API_URL = "https://api.anthropic.com/v1/completions";
const CLAUDE_API_KEY = process.env.CLAUDE_API_KEY!;
// DynamoDB client (uses VPC endpoint automatically because of the VPC connector)
const ddbClient = new DynamoDBClient({});
const ddbDoc = DynamoDBDocumentClient.from(ddbClient);
// S3 bucket where raw documents live
const S3_BUCKET = "my-rag-documents";
const router = express.Router();
/**
* POST /rag
* Body: { "question": "string", "k": number? }
*/
router.post("/rag", async (req, res) => {
const { question, k = 5 } = req.body;
if (!question) {
return res.status(400).json({ error: "question is required" });
}
try {
// 1️⃣ Vector search – retrieve top‑k chunk IDs
const vectorQuery = new QueryCommand({
TableName: "RagVectors",
IndexName: "VectorIndex",
// The new vectorSearch operator (2025‑09 release)
KeyConditionExpression: "vectorSearch(:qvec, :k)",
ExpressionAttributeValues: {
":qvec": {
// Convert the question to an embedding using your favorite model.
// Here we fake it with a placeholder; in production you’d call an embedding API.
"S": "placeholder‑embedding",
},
":k": { N: k.toString() },
},
});
const vectorResult = await ddbDoc.send(vectorQuery);
const chunkIds = (vectorResult.Items ?? []).map((item) => item.chunkId.S);
// 2️⃣ Fetch raw text from S3 for each chunk
const fetchChunk = async (id: string) => {
const url = `https://${S3_BUCKET}.s3.amazonaws.com/${id}.txt`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Failed to fetch ${id}`);
return resp.text();
};
const chunks = await Promise.all(chunkIds.map(fetchChunk));
// 3️⃣ Build the prompt for Claude
const prompt = `
You are a helpful assistant. Use only the information below to answer the user’s question.
--- Retrieved Context ---
${chunks.join("\n---\n")}
--- Question ---
${question}
`;
// 4️⃣ Call Claude
const claudeResp = await fetch(CLAUDE_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CLAUDE_API_KEY,
},
body: JSON.stringify({
model: "claude-3-5-sonnet-20240620",
max_tokens: 1024,
temperature: 0,
prompt,
}),
});
if (!claudeResp.ok) {
const err = await claudeResp.text();
throw new Error(`Claude error: ${err}`);
}
const { completion } = await claudeResp.json();
// 5️⃣ Respond to the client
res.json({
answer: completion,
usedChunkIds: chunkIds,
});
} catch (e) {
console.error(e);
res.status(500).json({ error: "internal server error" });
}
});
export default router;
Why each piece exists:
- The
vectorSearchquery is the heart of Retrieval‑Augmented Generation – it finds the most semantically similar pieces of text. - Fetching from S3 keeps the vector store tiny (just IDs and embeddings) while the raw documents stay cheap and durable.
- The prompt concatenates the retrieved chunks with the user question; this is the “augmented” part.
- Using
fetchto call Claude avoids pulling in a heavyweight SDK, keeping the container lean.
Wiring it into the server
// src/index.js
import express from "express";
import ragRouter from "./ragHandler.js";
const app = express();
app.use(express.json());
// Simple health‑check endpoint required by App Runner
app.get("/health", (_req, res) => res.sendStatus(200));
// Mount the RAG router
app.use("/", ragRouter);
// Listen on the port App Runner provides (defaults to 8080)
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`RAG API listening on port ${PORT}`);
});
Plain English recap: The server receives a question, looks up the most relevant chunks, pulls their full text, asks Claude to answer, and returns the answer.
Deploying the updated container
- Build and push the image to ECR (Elastic Container Registry).
- Update the App Runner service with the new image tag (the SDK call from earlier will pick it up automatically).
# Build
docker build -t rag-api:latest .
# Tag for ECR
docker tag rag-api:latest 123456789012.dkr.ecr.us-east-1.amazonaws.com/rag-api:latest
# Push (assumes you have logged in via `aws ecr get-login-password`)
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/rag-api:latest
Tip: Enable “Automatic deployments” in the App Runner console so that every new tag triggers a fresh rollout.
Managing Secrets and Observability with App Runner
Secrets
App Runner integrates with AWS Secrets Manager and AWS Systems Manager Parameter Store. Store your Claude API key there, then reference it in the service definition.
// When creating the service, add a secret binding
{
ServiceName: "rag-api",
SourceConfiguration: { /* … */ },
// SecretBinding allows the container to read the value from /run/secrets/
ServiceObservabilityConfiguration: {
ObservabilityEnabled: true,
},
InstanceConfiguration: { /* … */ },
// Attach secret
Secrets: [
{
Name: "CLAUDE_API_KEY",
ValueFrom: "arn:aws:secretsmanager:us-east-1:123456789012:secret:claude-key-abc123",
},
],
}
Inside the container the environment variable CLAUDE_API_KEY will be populated automatically. No hard‑coded credentials.
Key takeaway: Using managed secrets prevents accidental leaks and lets you rotate keys without redeploying code.
Observability
-
Logs – App Runner streams
stdout/stderrto CloudWatch Logs automatically. Ourconsole.errorstatements appear there. - Metrics – Request count, latency, and CPU/memory usage are emitted as CloudWatch metrics. You can create an alarm that notifies you when latency exceeds a threshold.
- Tracing – If you enable X‑Ray tracing in the service configuration, each request gets a trace that shows the DynamoDB query time, S3 fetch time, and Claude call latency.
// Example: add a simple request‑duration metric
import { Histogram } from "prom-client";
const requestHistogram = new Histogram({
name: "rag_request_duration_seconds",
help: "Duration of /rag requests",
buckets: [0.1, 0.5, 1, 2, 5],
});
router.post("/rag", async (req, res) => {
const end = requestHistogram.startTimer();
// … existing logic …
end(); // record duration
});
Tip: Export Prometheus metrics on
/metricsand let App Runner scrape them with a sidecar if you prefer that ecosystem.
Scaling, Cost, and When to Prefer ECS/Fargate
Scaling behavior
App Runner uses concurrency‑based scaling: it adds a new instance when the average request concurrency exceeds a configurable limit (default is 100). Each instance runs a single container, so warm‑up time is the same as the container start‑up time.
- Cold start – 10‑30 seconds for a fresh instance. Mitigate by sending a dummy request every few minutes.
- Warm scaling – Adding a second instance takes ~2‑3 seconds because the image is already cached.
Cost model
| Resource | Pricing (2026) | Approximate monthly cost for 10 k requests |
|---|---|---|
| App Runner compute (1 vCPU, 2 GB) | $0.064 per vCPU‑hour + $0.008 per GB‑hour | ~$8 (mostly idle) |
| DynamoDB read (vector query) | $0.25 per WCU‑hour | $1‑2 depending on k |
| S3 GET requests | $0.0004 per 1 000 GETs | <$0.01 |
| Claude API (pay‑per‑token) | $0.015 per 1 k input tokens, $0.030 per 1 k output | Varies, ~ $5‑10 for small queries |
Overall, the stack stays under $20/month for modest traffic, far cheaper than a constantly‑running EC2 instance.
When ECS/Fargate might be better
- Latency‑critical APIs – if you need sub‑100 ms response times, the 10‑second cold start of App Runner could be a blocker. ECS with a long‑running task or Fargate with provisioned concurrency avoids that.
- Complex networking – if you need multiple VPC endpoints (e.g., SageMaker, Elasticsearch) and want fine‑grained security groups per container, ECS gives more control.
- Persistent storage – App Runner does not support attached EFS volumes; if your service needs a local cache or session store, Fargate/ECS can mount EFS.
Bottom line: For a typical RAG service that sees intermittent traffic, App Runner wins on simplicity and cost. Switch to ECS/Fargate only when you hit the cold‑start or networking limits.
The Takeaway
- App Runner hides the servers: you just give it a Docker image and a VPC connector, and it takes care of HTTPS, scaling, and health checks.
-
A VPC connector is a private tunnel; remember to open outbound HTTPS to the DynamoDB endpoint, or you’ll see
AccessDeniedException. - The RAG flow: vector search in DynamoDB → fetch raw chunks from S3 → build a Claude prompt → return the answer. The code example shows a complete, commented Express handler that does exactly that.
- Secrets and observability are built‑in: store the Claude key in Secrets Manager, watch logs/metrics in CloudWatch, and optionally add Prometheus metrics.
- Cost stays low because you pay only for the compute time you actually use; a few dollars a month is typical for modest workloads.
- Consider ECS/Fargate only if you need ultra‑low latency, persistent storage, or more elaborate VPC setups.
With these steps you can launch a production‑grade Retrieval‑Augmented Generation API on AWS App Runner in under ten minutes, without wrestling with the usual cloud plumbing. Happy building!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-08-20 · 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)