Deploy to Fargate + S3 (production)
This guide moves a working local stack onto the production target: AWS ECS
Fargate for compute, S3 for artifact storage, the AWS credential chain, and an
S3 Object Lock audit anchor. The registry shape and client.dispatch(...)
contract are identical to the local path — the substitution is constructor
configuration, not application rewiring.
Prerequisites
Section titled “Prerequisites”- A working local dispatch first. Do the hello-world example on the local stack before swapping providers — it isolates “my wiring is wrong” from “my AWS setup is wrong.”
- An AWS account with an ECS cluster, a VPC with subnets and security groups, and an S3 bucket you control.
- AWS credentials resolvable by the standard SDK chain (env vars, shared config/
~/.aws, an instance/task role, or SSO). The provider does not take aregionor static keys — it reads the ambient chain. - A worker image published to a registry your Fargate task can pull (ECR or GHCR), pinned by digest.
1. Publish a pinned worker image
Section titled “1. Publish a pinned worker image”The local example tags :latest and sets allowUnpinnedImage: true so you can
iterate without resolving a digest. Production must not do either. The
FargateProvider rejects any non-@sha256: image reference unless
allowUnpinnedImage is set, and that flag is documented as dev/test only.
Build and push, then capture the digest:
docker build \ -t public.ecr.aws/quarry-systems/agora-worker:v1 \ -f docker/agora-worker/Dockerfile .
docker push public.ecr.aws/quarry-systems/agora-worker:v1
# Resolve the digest you will pin against:docker inspect --format='{{index .RepoDigests 0}}' \ public.ecr.aws/quarry-systems/agora-worker:v1You dispatch against the digest form, e.g.
public.ecr.aws/quarry-systems/agora-worker@sha256:<64-hex>.
2. Provision S3 storage (and the Object Lock audit tier)
Section titled “2. Provision S3 storage (and the Object Lock audit tier)”There are two distinct S3 concerns, and they are wired by different code. Do not conflate them.
Artifact storage is S3StorageProvider (@quarry-systems/agora-storage-s3).
It stores subagent/capability/env bundles and dispatch records. Create a bucket
for it. Its constructor takes only:
| Option | Meaning |
|---|---|
| bucket (required) | Target bucket. You create it; the provider does not. |
| prefix (optional) | Key prefix inside the bucket (slashes normalized). |
| client (optional) | A pre-built S3Client — pass this to set the region or a custom endpoint. |
The tamper-evidence tier is a separate seam. Object Lock that agora actively
uses lives in the audit anchor, not the storage provider. The orchestrator’s
S3ObjectLockAnchor (@quarry-systems/agora-orchestrator) writes the signed
Merkle root of each audit epoch to S3 Object Lock in compliance mode. That
is the external-immutable guarantee tier:
LocalAnchor(default) → guaranteedetect→ audit reports claimtamper-detecting: catches accidental or clumsy mutation, but the root lives in the same store, so it is not evidence against an attacker who controls the DB.S3ObjectLockAnchor→ guaranteeexternal-immutable→ audit reports claimtamper-evident: the signed root sits in S3 Object Lock compliance mode in your account — a different trust domain — and survives a DB-side tamper attempt.
S3ObjectLockAnchor is constructed with an injected S3LockClient seam
(the orchestrator takes no AWS SDK dependency directly), a bucket, and an
optional retention in days (default 3650 ≈ 10 years):
import { S3ObjectLockAnchor } from '@quarry-systems/agora-orchestrator';
// You supply the S3LockClient adapter: putObject(key, body, { retainUntil, mode: 'COMPLIANCE' })// and getObject(key). It writes versioned objects under audit/roots/<epochId>.json// with Object Lock compliance-mode retention.const anchor = new S3ObjectLockAnchor(s3LockClient, 'my-org-agora-audit', 3650);Create the audit bucket with Object Lock enabled at creation time (S3 requires this; it cannot be turned on later) and grant compliance-mode retention permissions to the principal that runs the orchestrator.
3. Swap providers in agora.config
Section titled “3. Swap providers in agora.config”The local stack uses LocalDockerProvider, LocalStorageProvider, and
NoopCredentialProvider. Swap each for its AWS counterpart. The class names and
options below are the real exported shapes from the provider packages.
import { AgoraClient, StdoutResultSink } from '@quarry-systems/agora-client';import { FargateProvider } from '@quarry-systems/agora-providers-fargate';import { S3StorageProvider } from '@quarry-systems/agora-storage-s3';import { AwsCredentialProvider } from '@quarry-systems/agora-providers-aws-creds';import { S3Client } from '@aws-sdk/client-s3';
// Region (and any endpoint override) is set on the S3Client, NOT on the// provider — S3StorageProvider has no `region` option.const s3 = new S3Client({ region: 'us-east-1' });
const client = new AgoraClient({ namespace: 'hello-world', compute: { fargate: new FargateProvider({ cluster: 'arn:aws:ecs:us-east-1:123456789012:cluster/agora', // Family name WITHOUT revision — RunTask resolves the latest active // revision. Pin a specific one with `family:N`. taskDefinitionFamily: 'agora-worker', subnets: ['subnet-abc', 'subnet-def'], securityGroups: ['sg-xyz'], // Default is 'DISABLED'; only ENABLE in a public subnet with no NAT. assignPublicIp: 'DISABLED', }), }, // The AWS provider reads the ambient SDK credential chain. No region/keys // option — it has only `providerOverride` for custom assume-role flows. credentials: { aws: new AwsCredentialProvider(), }, storage: new S3StorageProvider({ bucket: 'my-org-agora-artifacts', client: s3, // prefix: 'agora', // optional // Set server-side encryption on every object agora writes. Omit to inherit // the bucket default (SSE-S3, on by default since 2023). For customer-managed // keys: encryption: { mode: 'aws:kms', kmsKeyId: 'arn:aws:kms:...:key/...' } encryption: { mode: 'AES256' }, }), targets: { prod: { compute: 'fargate', credentials: 'aws' } }, resultSink: new StdoutResultSink(),});4. Configure the Fargate target
Section titled “4. Configure the Fargate target”The provider drives RunTask/DescribeTasks/StopTask, but several things are
fixed by the task definition, not by the dispatch. Get these right or the
dispatch will fail at launch:
- Container name must be
agora-worker. The provider overrides exactly this container’s environment, command, cpu, and memory per dispatch; if the task definition’s container is named anything else, the override is dropped. - Image is locked in the task definition.
RunTaskcannot change it — pin the same digest you dispatch with (see step 1). - Secrets must be pre-declared in the task definition’s
secrets:[]. The provider throws if a dispatch carriessecretRefs, becauseRunTaskcannot inject new secrets at launch. Declare each secret (e.g. yourANTHROPIC_API_KEY, sourced from AWS Secrets Manager via theAwsSecretStoreadapter) in the task definition so it is present when the task starts. awsvpcnetworking. ThesubnetsandsecurityGroupsyou pass populate theawsvpcConfiguration. Make sure the security group and subnet routing let the task reach S3, Secrets Manager, your model endpoint, and the image registry (NAT gateway, VPC endpoints, orassignPublicIp: 'ENABLED'in a public subnet).- Logging. The provider does not capture stdout/stderr from Fargate
(
awaitExitreturns empty strings for both). Wire the task definition’sawslogsdriver to CloudWatch Logs and read worker output there.
5. Dispatch and verify
Section titled “5. Dispatch and verify”The dispatch call is unchanged from local — only target and the pinned
workerImage differ:
const result = await client.dispatch({ subagent: 'echo', env: 'minimal', target: 'prod', // Digest-pinned — FargateProvider rejects unpinned images. workerImage: 'public.ecr.aws/quarry-systems/agora-worker@sha256:0123456789abcdef...',});awaitExit polls DescribeTasks (default every 5000 ms; tune with
pollIntervalMs) until the task reaches STOPPED, then projects the container’s
exit code into the result. A non-zero exit, or an infrastructural
stoppedReason, surfaces as a failure — exactly as on the local path. Read the
worker’s structured-log stream from CloudWatch (see step 4.5) since the provider
does not return it inline.
Then prove the audit trail. With S3ObjectLockAnchor in force, the audit report
names the anchor and claims the tamper-evident tier; with LocalAnchor it
claims tamper-detecting.
Next steps
Section titled “Next steps”- Export & verify an audit bundle — produce and check the tamper-evident bundle this anchor backs.
- Audit & guarantee tiers — the full model behind
detect/external-immutable/witnessedand thetamper-detectingvstamper-evidentdistinction.