AWS runbook
Same one-shot as Local Docker, Cloudflare, and Azure: Bench Ticket with first-party email-code login, Sessions, Support Desk create/list/reply, Health. Mail is a console catcher (CloudWatch logs) — do not sign up for SES, SendGrid, ACS, or Resend. Do not add Stripe, Auth0, Entra, or Cognito.
Requires the storage-core DynamoDB adapter PR
Record persistence on AWS uses the Storage Core adapter that passes packages/storage-core/src/conformance.ts. That package is being added in pegma-dev/storage-core#7 as DynamoDB, named to match siblings:
| Sibling | Package | Factory |
|---|---|---|
| Azure Tables | @pegma/storage-azure-tables |
createAzureTablesStore |
| Cloudflare D1 | @pegma/storage-cloudflare-d1 |
createCloudflareD1Store |
| AWS DynamoDB | @pegma/storage-dynamodb |
createDynamoDbStore |
Do not implement that adapter in pegma.dev. If pnpm add @pegma/storage-dynamodb fails because the package is not on npm yet, stop and merge/publish storage-core#7 first, then resume at step 4. Blobs are already published (@pegma/storage-s3 / createS3BlobStore).
Until that package is on npm, Local Docker, Cloudflare, and Azure still one-shot from this same fixture.
Prerequisites
- Git, Docker, Node 22/24 with Corepack.
- AWS account.
- Authenticated AWS CLI:
aws sts get-caller-identitymust print an account. If it fails:aws loginoraws configure sso(or export keys) and retry. - Region default:
us-east-1.
1. Get the source
git clone https://github.com/pegma-dev/pegma.dev.git
cd pegma.dev
git checkout main
npm install -g corepack
corepack enable
pnpm install --frozen-lockfile
2. Default names
| Resource | Default name |
|---|---|
| Region | us-east-1 |
| DynamoDB table | bench-ticket (every Storage Core collection shares one table) |
| S3 bucket | bench-ticket-blobs-<account-id> (globally unique) |
| App Runner / container service | bench-ticket |
| Email-code secret | Secrets Manager bench-ticket/email-code-hmac, injected via App Runner RuntimeEnvironmentSecrets (or ECS secrets) as BENCH_TICKET_EMAIL_CODE_SECRET_BASE64 |
The adapter README’s table schema is pk (HASH) + sk (RANGE), on-demand billing. Mirror Azure: one table for every collection, not one table per collection.
3. Create AWS resources
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REGION=us-east-1
BUCKET="bench-ticket-blobs-${ACCOUNT}"
aws dynamodb create-table \
--region "$REGION" \
--table-name bench-ticket \
--billing-mode PAY_PER_REQUEST \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE
aws dynamodb wait table-exists --region "$REGION" --table-name bench-ticket
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION"
The adapter can create the table on first use (createTableIfMissing defaults to true). This runbook provisions it in infrastructure instead, then passes createTableIfMissing: false so the task role does not need CreateTable. Do not invent a second table “for audit”.
4. Point the host at DynamoDB (after the adapter is on npm)
pnpm add @pegma/storage-dynamodb @aws-sdk/client-dynamodb @pegma/storage-s3 @aws-sdk/client-s3
Pin @pegma/storage-dynamodb to the exact version in catalog.json once the compiler lists it. Until then, use the version the storage-core release publishes (do not invent a caret range).
In recipes/bench-ticket/host.ts, replace createMemoryStore() with the sibling-shaped factory:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { createDynamoDbStore } from "@pegma/storage-dynamodb";
const store = createDynamoDbStore({
client: new DynamoDBClient({ region: process.env.AWS_REGION ?? "us-east-1" }),
tableName: process.env.BENCH_TICKET_TABLE ?? "bench-ticket",
createTableIfMissing: false,
});
If the published factory options differ, copy them from that package’s README — keep the call site a single createDynamoDbStore({ … }) returning a Storage Core Store, then pass it to createBenchTicketComposition unchanged.
Optional blobs (provisioned above; not required for the ticket round-trip):
import { S3Client } from "@aws-sdk/client-s3";
import { createS3BlobStore } from "@pegma/storage-s3";
const blobs = createS3BlobStore({
client: new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" }),
bucket: process.env.BENCH_TICKET_BLOB_BUCKET!,
endpoint: `https://s3.${process.env.AWS_REGION ?? "us-east-1"}.amazonaws.com`,
maxObjectBytes: 16 * 1024 * 1024,
});
Set BENCH_TICKET_MAIL_CATCHER=console.
5. Deploy the container
Use the same Dockerfile as Local Docker. Example with App Runner (any container host that can reach DynamoDB is fine):
SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REGION=us-east-1
ECR="$ACCOUNT.dkr.ecr.$REGION.amazonaws.com"
aws ecr create-repository --repository-name bench-ticket --region "$REGION" || true
aws ecr get-login-password --region "$REGION" \
| docker login --username AWS --password-stdin "$ECR"
docker build -f recipes/bench-ticket/Dockerfile -t "$ECR/bench-ticket:local" .
docker push "$ECR/bench-ticket:local"
aws secretsmanager create-secret \
--name bench-ticket/email-code-hmac \
--secret-string "$SECRET" \
--region "$REGION"
Create an App Runner service (or ECS/Fargate) with:
| Name | Value |
|---|---|
PORT |
8787 (plaintext env) |
BENCH_TICKET_MAIL_CATCHER |
console (plaintext env) |
BENCH_TICKET_EMAIL_CODE_SECRET_BASE64 |
RuntimeEnvironmentSecrets from Secrets Manager bench-ticket/email-code-hmac (ARN as the map value) — not RuntimeEnvironmentVariables |
BENCH_TICKET_TABLE |
bench-ticket (plaintext env) |
AWS_REGION |
us-east-1 (plaintext env) |
BENCH_TICKET_ORIGIN |
https://<assigned-host> (plaintext env; set after first URL exists, then redeploy) |
The process still reads BENCH_TICKET_EMAIL_CODE_SECRET_BASE64 from its environment at runtime. On App Runner, put the Secrets Manager ARN in ImageConfiguration.RuntimeEnvironmentSecrets (API field; CLI: nested under --source-configuration / --cli-input-json). That map is distinct from RuntimeEnvironmentVariables. On ECS/Fargate, use a task-definition secrets entry. Do not put $SECRET in RuntimeEnvironmentVariables or a task-definition environment block — DescribeService and describe-task-definition return those values.
Give the App Runner instance role (or ECS task role) DynamoDB access to table bench-ticket, s3:GetObject/PutObject on the blob bucket, and secretsmanager:GetSecretValue on bench-ticket/email-code-hmac. Do not put AWS keys in the image.
6. Verify
ORIGIN=https://REPLACE.awsapprunner.com
curl -sS "$ORIGIN/health"
Expect HTTP 200, "ok": true, "service": "bench-ticket". Then the same begin / 8-digit code from logs / finish / file ticket / reply sequence as Local Docker. Log prefix: [bench-ticket mail].
Follow logs with the platform’s tail (aws logs tail … --follow or App Runner application logs).
Failure signs
| Sign | Next command |
|---|---|
aws sts get-caller-identity fails |
aws login / aws configure sso |
pnpm add @pegma/storage-dynamodb 404 |
Adapter PR not merged/published. Do not implement DynamoDB here. Finish Local Docker / Cloudflare / Azure instead. See storage-core#7. |
Cannot find module '@pegma/storage-dynamodb' |
Same — wait for the storage-core AWS package; name it @pegma/storage-dynamodb / createDynamoDbStore. |
ResourceNotFoundException |
aws dynamodb describe-table --table-name bench-ticket. Recreate using pk HASH + sk RANGE. |
| Health 503 / storage fail | IAM: the task role needs DynamoDB on that table. aws dynamodb scan --table-name bench-ticket --max-items 1. |
| Origin invalid / finish 400 | BENCH_TICKET_ORIGIN must be the public HTTPS origin with no path. |
HMAC printed by DescribeService / task definition |
Recreate it as RuntimeEnvironmentSecrets (Secrets Manager ARN) or an ECS secrets entry. Do not put the value in RuntimeEnvironmentVariables. |
| No code in logs | Tail logs before begin. BENCH_TICKET_MAIL_CATCHER=console. |
| Temptation to use Cognito | Stop. This demo is @pegma/identity email-code only. |
Teardown
aws dynamodb delete-table --table-name bench-ticket --region us-east-1
aws s3 rb "s3://bench-ticket-blobs-${ACCOUNT}" --force
aws ecr delete-repository --repository-name bench-ticket --force --region us-east-1
aws secretsmanager delete-secret \
--secret-id bench-ticket/email-code-hmac \
--force-delete-without-recovery \
--region us-east-1
# plus delete the App Runner / ECS service
rm -f /tmp/bench-ticket.cookies
Store wiring (same composition)
After the adapter exists, the host is the Azure/D1 shape:
import { createDynamoDbStore } from "@pegma/storage-dynamodb";
const store = createDynamoDbStore({
client,
tableName: "bench-ticket",
createTableIfMissing: false,
});
Pass store into createBenchTicketComposition. Do not give Audit, Mail, Identity, or Support Desk a second DynamoDB table.