AWS
Deploy ctxpipe on AWS with the @ctxpipe/aws-cdk CtxPipe construct.
@ctxpipe/aws-cdk is the AWS self-hosting path for ctxpipe. It gives you one high-level construct, CtxPipe, that provisions the required infrastructure and wires runtime configuration for you.
Single-tenant only
Self-hosted ctxpipe on @ctxpipe/aws-cdk is single-tenant. Run one stack
per organization.
Step-by-step deployment flow
1) Install @ctxpipe/aws-cdk in your CDK repo
Use an existing CDK repo, or create a new one first.
Then install:
pnpm add @ctxpipe/aws-cdk aws-cdk-lib constructs2) Create or reuse a Route 53 hosted zone
You need a hosted zone for your custom domain because the construct configures DNS and TLS from it.
- Reuse an existing public hosted zone if you already manage your domain in Route 53.
- Or create a new hosted zone and delegate your domain/subdomain to it.
- Keep both values ready:
customDomain.domainName(example:app.example.com)customDomain.hostedZoneId(example:Z0123456789ABCDEF)
3) Import CtxPipe and configure it
Instantiate the construct in your stack. Follow the same pattern as
examples/aws-cdk-self-host.
modelProvider is a union: openai-like (default) or bedrock. Both variants configure tiers through the same models prop (fast required).
OpenAI-compatible provider
import * as cdk from "aws-cdk-lib";
import { CtxPipe } from "@ctxpipe/aws-cdk";
const app = new cdk.App();
const stack = new cdk.Stack(app, "CtxPipeStack");
const modelApiKey = app.node.tryGetContext("modelApiKey");
if (!modelApiKey) {
throw new Error('Missing CDK context "modelApiKey". Pass -c modelApiKey=...');
}
new CtxPipe(stack, "CtxPipe", {
orgSlug: "acme",
size: "medium",
customDomain: {
domainName: "app.example.com",
hostedZoneId: "Z0123456789ABCDEF",
},
modelProvider: {
baseUrl: "https://api.openai.com/v1",
apiKey: cdk.SecretValue.unsafePlainText(String(modelApiKey)),
models: {
fast: "gpt-4.1-mini",
embedding: "text-embedding-3-large",
// Optional: medium, high
// medium: "gpt-4.1",
// high: "gpt-4.1",
},
},
});Set models.embedding for an OpenAI-compatible provider. Without it, the
backend uses openai/text-embedding-3-large, an OpenRouter model id that other
providers do not know.
Amazon Bedrock (task role)
For Bedrock, set kind: "bedrock" and tier model IDs only. The construct grants the backend/worker ECS task roles bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream, injects MODEL_PROVIDER=bedrock and MODEL_BEDROCK_AWS_REGION, and does not create a MODEL_PROVIDER_API_KEY secret. The backend calls Bedrock Runtime with SigV4 credentials from the ECS task role.
Enable each model ID in the Amazon Bedrock console for your stack region before relying on chat or embeddings.
Bedrock embeddings (Cohere only)
Bedrock embeddings currently support Cohere embed models only (the backend uses Cohere's invoke format). Default when models.embedding is omitted: cohere.embed-v4:0. You can also use inference-profile IDs such as global.cohere.embed-v4:0. Non-Cohere Bedrock embed models (for example Titan) are not supported yet.
Organizations SCPs can still block Bedrock
The construct attaches bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream to the backend and worker task roles, but an AWS Organizations service control policy (SCP) can override that with an explicit deny. Typical log text: AccessDeniedException … explicit deny in a service control policy. Allow your chat and Cohere embed foundation-model ARNs in the SCP (denies for global.cohere.embed-v4:0 often name …/foundation-model/cohere.embed-v4:0). This is separate from chat stream stalls such as Bedrock Converse stream timed out … without receiving a chunk—those need CloudTrail ConverseStream investigation, not a wider task-role policy.
new CtxPipe(stack, "CtxPipe", {
orgSlug: "acme",
customDomain: {
domainName: "app.example.com",
hostedZoneId: "Z0123456789ABCDEF",
},
modelProvider: {
kind: "bedrock",
region: "us-east-1", // optional; defaults to stack region
models: {
fast: "anthropic.claude-sonnet-4-20250514-v1:0",
medium: "anthropic.claude-sonnet-4-20250514-v1:0",
high: "anthropic.claude-opus-4-20250514-v1:0",
embedding: "cohere.embed-v4:0", // optional; Cohere models only (e.g. global.cohere.embed-v4:0)
},
},
});4) Run CDK deploy
OpenAI-like:
cdk synth
cdk deploy -c modelApiKey="$OPENAI_API_KEY"Bedrock, with the example app in
examples/aws-cdk-self-host.
It always uses Bedrock, and reads orgSlug, domainName, hostedZoneId,
size, stackName, modelFast, modelMedium, modelHigh, and
modelEmbedding from the context:
cdk deploy \
-c modelFast="anthropic.claude-sonnet-4-20250514-v1:0" \
-c orgSlug=acme \
-c domainName=app.example.com \
-c hostedZoneId=Z0123456789ABCDEF5) Wait for deployment and open your custom domain
After cdk deploy completes, open https://<customDomain.domainName>.
6) Complete onboarding with the same orgSlug
When you create the first organization in onboarding, use the same slug
as the orgSlug of CtxPipe. The construct uses the slug for the graph
database settings of the organization (GRAPH_DB_URI_<orgSlug>). If the two
slugs are different, the graph of the organization does not work. The slug can
contain only lowercase letters, digits, and -.
7) Upgrade an existing stack
A new @ctxpipe/aws-cdk version on npm is the release unit. It pins backend, worker, UI, codesearch, and migrate images to one git SHA and includes that release’s Fargate size profiles. After it is published:
pnpm update @ctxpipe/aws-cdk
cdk synth
cdk deployBefore the first deploy of a new version, check the Aurora minor version. See Aurora PostgreSQL minor version.
cdk deploy runs Postgres migrations, then rolls the ECS services. You do not set image tags in CtxPipe props, and you should not retag tasks to :latest yourself. The migrate task provisions ctxpipe_app, and then the stack writes the runtime DATABASE_URL secret; CDK operators do not set either URL by hand and do not run psql.
This is how codesearch memory bumps (4 / 8 / 12 GiB) and ingest behavior land together. Updating only container images, or only task memory, leaves the stack split across releases.
The first upgrade to a version with the chat sandbox host adds it with no new required props. That deploy only adds resources and replaces nothing that holds data. It waits until the host reports ready, then rolls backend and worker.
To automate upgrades, let Renovate or Dependabot open a pull request for each
new @ctxpipe/aws-cdk version, and run cdk synth and cdk deploy from your
pipeline.
Why each prop exists
Required props
| Prop | Why it is required |
|---|---|
orgSlug | Self-hosted AWS CDK deployments are single-tenant. The slug identifies the one org for this stack and is used for org-scoped graph runtime values such as GRAPH_DB_URI_<orgSlug>. |
customDomain | ctxpipe needs one stable public HTTPS origin for browser traffic, auth, API routes, connector callbacks, and MCP. The construct uses this to configure DNS + TLS + ALB listeners. |
modelProvider | Backend and worker need model IDs and either an OpenAI-compatible endpoint + API key, or Bedrock via ECS task role. See union below. |
Optional props
| Prop | Behavior |
|---|---|
size | Capacity profile for single-tenant deployments. Valid values: small, medium, large. Defaults to small when omitted. |
otel | Optional OTLP export. Omit it and the tasks export nothing. The construct does not deploy a collector, Langfuse, or ClickStack. |
sandboxHost | The chat sandbox host: instanceType, dockerVolumeSizeGiB (a whole number, at least 20), agentVaultNetworkAllowlist for a private git server, and enabled. The host is on by default. enabled: false creates no host, and turns chat off. |
connectorSecrets | Deployment-wide connector credentials. See Optional connector secrets. |
modelProvider union
| Variant | kind | Required nested fields | Runtime auth |
|---|---|---|---|
| OpenAI-compatible | omitted or openai-like | baseUrl, apiKey, models.fast | MODEL_PROVIDER_API_KEY in Secrets Manager |
| Amazon Bedrock | bedrock | models.fast | ECS task role → native Bedrock Runtime (SigV4); no model API key secret |
Optional on both variants: models.medium, models.high, and models.embedding.
Omitted tier IDs cascade: medium → fast, high → medium. The construct maps them to MODEL_FAST_NAME, MODEL_MEDIUM_NAME, MODEL_HIGH_NAME, and MODEL_EMBEDDING_NAME. See Model configuration for what each tier is used for.
For bedrock, modelProviderSecret / modelProviderSecretArn stack outputs are omitted.
Upgrading from older @ctxpipe/aws-cdk versions
| Removed | Replacement |
|---|---|
modelProvider.defaultModel | modelProvider.models.fast (required) |
Replace defaultModel: "gpt-4.1-mini" with models: { fast: "gpt-4.1-mini" }.
Required nested fields (openai-like)
| Field | Why it is required |
|---|---|
customDomain.domainName | Becomes the public app origin (AUTH_BASE_URL / CTXPIPE_PUBLIC_APP_URL) served over HTTPS. This is the URL users visit for sign-in and onboarding. |
customDomain.hostedZoneId | Used to create/validate Route 53 records for ACM certificate validation, ALB alias records, and SES domain identity/DKIM records. |
modelProvider.baseUrl | Endpoint for OpenAI-compatible model APIs used by backend and worker. |
modelProvider.apiKey | Secret used to authenticate model API requests at runtime. |
modelProvider.models.fast | Model ID for the fast tier (MODEL_FAST_NAME). |
Required nested fields (bedrock)
| Field | Why it is required |
|---|---|
customDomain.domainName | Same as openai-like — public HTTPS origin. |
customDomain.hostedZoneId | Same as openai-like — DNS/TLS/SES records. |
modelProvider.models.fast | Bedrock model ID for the fast tier (MODEL_FAST_NAME). Enable it in the Bedrock console for the stack region. |
Optional connector secrets
Pass deployment-wide connector credentials through connectorSecrets. Each
value is a cdk.SecretValue. The construct stores them in Secrets Manager and
gives them to the ECS tasks that need them. Most connectors can also take their
credentials in the setup wizard of the product, so these are optional.
| Connector | Keys |
|---|---|
| GitHub App | githubAppId, githubPrivateKey, githubWebhookSecret, githubClientId, githubClientSecret |
| Atlassian | atlassianClientId, atlassianClientSecret |
| Slack | slackClientId, slackClientSecret, slackSigningSecret (set all three) |
| Linear | linearClientId, linearClientSecret, linearRedirectUri, linearWebhookSecret |
| Notion | notionClientId, notionClientSecret, notionWebhookSecret |
| PagerDuty | pagerdutyClientId, pagerdutyClientSecret, pagerdutyRedirectUri. There is no shared webhook secret. |
See Self-host Slack and Self-host PagerDuty for callback and webhook settings.
Observability
| Prop | Env on backend, worker, UI, and codesearch |
|---|---|
endpoint | OTEL_EXPORTER_OTLP_{TRACES,LOGS,METRICS}_ENDPOINT |
headers | OTEL_EXPORTER_OTLP_HEADERS (Secrets Manager) |
resourceAttributes | OTEL_RESOURCE_ATTRIBUTES |
Browser telemetry and the signal URLs the apps read are in Configuration. Prop reference: package README.
High-level architecture
CtxPipe provisions a single AWS stack with these layers:
| Layer | What is provisioned | Purpose |
|---|---|---|
| Network | VPC with public/private subnets and NAT egress | Isolates internal services while allowing controlled outbound access. |
| Edge + ingress | Public ALB + ACM certificate + Route 53 records | Exposes one HTTPS origin and routes public traffic to backend. |
| Compute | ECS cluster + Fargate services (backend, worker, ui, codesearch) | Runs application APIs, background jobs, UI, and code indexing/search workloads. |
| Chat sandboxes | One EC2 Docker host in an Auto Scaling group, Docker volume, TLS secrets, CloudWatch alarms | Runs each Workspace conversation's agent in its own container. |
| Stateful data | Aurora PostgreSQL 16, Neptune cluster, EFS for /data | Persists relational state, graph data, and codesearch cache/indexes. |
| Secrets + identity | Secrets Manager secrets, generated AUTH_SECRET, optional connector secrets | Stores runtime credentials and injects them into tasks securely. |
| Email delivery | SES domain identity, DKIM, SMTP credentials | Enables transactional email from backend (ctxpipe-noreply@<hosted-zone-apex>). |
| Deploy safety | Migration custom resource (one-off ECS task) + ECS deployment rollback controls | Runs migrations during deploy and prevents partial/broken rollouts. |
Only the backend is public. UI and codesearch stay internal and are reached through backend routing.
Chat sandbox host
Workspace chat runs each conversation's agent (OpenCode) in its own Docker
container. CtxPipe creates one EC2 host for those containers by default. The
sandbox keeps the agent's commands away from the backend and your data stores.
To create no host, set sandboxHost: { enabled: false }. Without the host,
Workspace chat is off: the product answers chat requests with a "sandbox
unavailable" error (503). The rest of the product keeps working.
Sizes and cost
The host mostly holds idle sandboxes, so it uses burstable Graviton (ARM) instances. Memory is the limit: an active sandbox uses about 0.3–0.5 GiB. Sandboxes stop after 5 minutes idle and resume on the next message. Stopped sandboxes are removed 30 days after last use. An organization runs at most 50 sandboxes at once.
size | Instance | vCPU / RAM | Docker volume (gp3) | ~Cost/month | Running sandboxes (est.) |
|---|---|---|---|---|---|
small (default) | t4g.medium | 2 / 4 GiB | 30 GB | ~$28 | ~6 |
medium | t4g.large | 2 / 8 GiB | 50 GB | ~$54 | ~14 |
large | t4g.xlarge | 4 / 16 GiB | 100 GB | ~$107 | ~30 |
Costs are us-east-1 on-demand prices for the instance, the Docker volume, and
a 16 GB root volume. t4g instances run in unlimited mode, so sustained CPU
above baseline adds a small surcharge.
Override either value with sandboxHost:
import * as ec2 from "aws-cdk-lib/aws-ec2";
new CtxPipe(stack, "CtxPipe", {
// ...orgSlug, customDomain, modelProvider
sandboxHost: {
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.XLARGE),
dockerVolumeSizeGiB: 150,
},
});The instance type must be Graviton (arm64); other architectures are rejected at synth.
Private git servers
Ingestion can read a plain git repository on a git server inside your network
(see Plain git repositories).
The ingestion sandbox fetches it through Agent Vault on the sandbox host, and
Agent Vault connects only to the private addresses in its allowlist. By
default, the allowlist holds only the backend subnets. Add the single host
address of your git server with sandboxHost.agentVaultNetworkAllowlist:
new CtxPipe(stack, "CtxPipe", {
// ...orgSlug, customDomain, modelProvider
sandboxHost: {
agentVaultNetworkAllowlist: ["10.20.0.15/32"],
},
});Chat-sandbox vaults forward each host that no rule matches. An address or CIDR in this list therefore lets every chat agent reach every port and path at that address. Add only the single host address of the git server, never a whole subnet.
The stack adds each entry after the backend subnets. Each entry must be an IPv4 or IPv6 address or CIDR. Synth rejects an entry with a cloud metadata or link-local address. The sandbox host must also have a route to that address, for example VPC peering or a transit gateway. The git server must accept connections from the host. A change to the list replaces the host, as each change to its boot script does.
What it sets up
| Piece | Behavior |
|---|---|
| Instance | Amazon Linux 2023 in a single-instance Auto Scaling group in the private subnets. EC2 health checks replace a failed host. cdk deploy waits until the host reports ready. New AMIs apply when the host is replaced, not on every deploy. |
| Docker volume | gp3 volume at /var/lib/docker. It belongs to the instance, so a replaced host starts empty. Conversations keep their work in their git branch and continue on the new host. |
| Docker API | Port 2376 with mutual TLS at sandbox-host.ctxpipe.local, a Cloud Map name the host registers when it boots. The host creates the certificates on first boot and stores them in Secrets Manager; a replacement host reuses them. The CA key is not kept. |
| Network | Backend and worker reach the Docker API and the Agent Vault API (port 14321). Only the backend reaches the sandboxes' agent ports. DOCKER-USER rules let sandboxes reach only the Agent Vault proxy (port 14322); a direct connection, a non-HTTP protocol, and DNS are rejected. Through the proxy, sandboxes reach Git hosts, the internet, and the backend (model proxy and per-run tool bridges), but not Postgres, Neptune, EFS, codesearch, the UI, or the worker. This is the chat sandbox network policy shared with Docker Compose. |
| Credentials | Agent Vault (infisical/agent-vault:latest) runs on the host on its own Docker bridge. Sandboxes hold no credential: Agent Vault adds the GitHub token and the backend credentials to each request. Agent Vault may dial the backend's subnets, and the entries of sandboxHost.agentVaultNetworkAllowlist. The backend subnets are the stack's only private subnets, so they also hold RDS, Neptune, EFS, and the UI and codesearch tasks; only security groups keep the proxy out of them. If you change security groups, never admit the sandbox host's group to those services. The stack generates its master password and owner password in Secrets Manager; the host reads the master password, and backend and worker get the owner password. If Agent Vault does not answer, chat fails closed. A replaced host starts an empty Agent Vault, and the backend registers again on first use. |
| Isolation | Each agent port needs a per-conversation password. Agent ports are reachable inside the VPC, so the host also blocks sandboxes from each other, from the host itself, and from instance metadata. |
| Limits | All sandbox containers share one systemd slice capped at 85% of host memory. Container logs rotate at 3 × 10 MB. |
| Chat image | ghcr.io/ctxpipe-ai/chat-sandbox, published for arm64 and amd64 with the same release tag as the service images. The backend pulls it through the host's Docker daemon on the first chat after a release, so releases never replace the host. |
| Backend and worker | Get SANDBOX_PROVIDER=docker, SANDBOX_CHAT_IMAGE, DOCKER_HOST=tcp://sandbox-host.ctxpipe.local:2376, DOCKER_TLS_VERIFY=1, and DOCKER_CERT_PATH. A short init container in each task writes the client certificates there as files. They also get AGENT_VAULT_ADDR and the AGENT_VAULT_OWNER_PASSWORD secret. Sandboxes call the backend back at its task's VPC DNS name (ip-….compute.internal), so they reach the replica that runs their turn. The stack sets SANDBOX_CALLBACK_DNS_SUFFIX for this. The VPC must have DNS host names and DNS resolution on; the stack's VPC has both. |
Alarms
| Alarm | Fires when | What to do |
|---|---|---|
| Docker disk | /var/lib/docker is 80% full or more for 10 minutes | Check docker system df on the host (see Operations). Raise sandboxHost.dockerVolumeSizeGiB if the volume fills with live sandboxes. |
| Memory | Host memory is 85% used or more for 15 minutes | Sandboxes are near their cap. Move to a larger size or sandboxHost.instanceType. |
The alarms have no actions. Subscribe them to your own topic through the construct:
import * as actions from "aws-cdk-lib/aws-cloudwatch-actions";
const ctxPipe = new CtxPipe(stack, "CtxPipe", { /* ... */ });
for (const alarm of ctxPipe.sandboxHostAlarms) {
alarm.addAlarmAction(new actions.SnsAction(opsTopic));
}Chat needs a sandbox
The backend and worker images refuse SANDBOX_PROVIDER=unsandboxed: an
agent without a sandbox would run with the backend's credentials. CtxPipe
configures a Docker host for chat sandboxes.
What this gives you by default
GRAPH_DB_PROVIDER=neptuneand graph URI wiring from Neptune endpoints- Internal service routing (
UI_PROXY_URL,CODESEARCH_URL) so only backend is public - Generated
AUTH_SECRETand managedDATABASE_URL(runtimectxpipe_app) / SMTP secrets - Public app URL output and secret ARN outputs for operations
How to choose size
Use size to tune compute and database capacity for your single-tenant workload:
small(default): best for pilot/small-team deployments with moderate repository churn and cost sensitivity. Aurora and Neptune both usedb.t4g.medium— AWS’s minimum supported burstable size for these engines (dev/test oriented). Codesearch is 4 GiB (ingest peak, not idle RSS).medium: better for sustained ingestion/reindex workloads where worker and codesearch need more headroom. Codesearch is 8 GiB. Aurora usesdb.t4g.large; Neptune uses memory-optimizeddb.r6g.large.large: for high-ingestion and lower-latency expectations, with bigger tasks, scaled backend/worker replicas, anddb.r6g.xlargefor both Aurora and Neptune. Codesearch is 12 GiB and still one task. The two workers each getOPENWORKFLOW_CONCURRENCY=8so the cluster cannot stampede codesearch. Extra workers help extract and connectors, not clone/SCIP parallelism.
See the package README sizing table for the full preset matrix.
Do not raise codesearch desired count. Ingest throughput grows with codesearch memory (4 / 8 / 12 GiB) and the env the construct injects (OPENWORKFLOW_CONCURRENCY, CODESEARCH_INDEXER_CONCURRENCY, CODESEARCH_INDEX_PIPELINE_CONCURRENCY).
Signals that you should move up a size:
- ingestion jobs queueing up or taking too long,
- frequent codesearch reindex delays,
- higher p95 response latency during active sync/index windows.
@ctxpipe/aws-cdk keeps services in private subnets for every size. NAT remains required because backend/worker/codesearch need outbound access for model providers, GitHub, Atlassian, and Linear APIs.