AgentCore Runtime doesn't run your TypeScript — it runs a container image it pulls from ECR. So before any AgentCore resource can exist, we do two things: put the lesson-02 HttpApi server in a container that listens on 0.0.0.0:8080, and declare the ECR repository in the same Alchemy program — the first resource yielded inside the Alchemy.Stack generator. By the end the image is in the registry, ready for the Runtime to reference in lesson 05.
The deploy artifact is not source, not a zip, not a function handler — it is an OCI container image. AgentCore Runtime pulls that image from ECR and runs it, then talks to it over the lesson-02 contract: GET /ping and POST /invocations, on 0.0.0.0:8080. Everything in this lesson exists to produce that one artifact and put it somewhere the Runtime can reach.
So the question this lesson answers is mechanical: how do we turn the running server from lesson 02 into an image, and where does that image live? The answer is a Dockerfile plus an ECR repository declared in Alchemy.
The container's only job is to start the lesson-02 server. Rather than ship node_modules and a TypeScript loader, the image is built in two stages: a build stage bundles src/agent.ts into one self-contained ESM file with esbuild (inlining effect, @effect/platform-node, and the AWS SDK), and a run stage ships only node + that bundle. No node_modules, no tsx, no pnpm at runtime — a tiny image that starts instantly:
FROM node:24-slim AS build
WORKDIR /app
# pnpm-workspace.yaml is needed at install time, so copy it alongside package.json.
COPY package.json pnpm-workspace.yaml ./
RUN corepack enable && pnpm install
COPY . .
RUN pnpm bundle # esbuild → dist/agent.mjs
FROM node:24-slim AS run
WORKDIR /app
COPY --from=build /app/dist/agent.mjs ./agent.mjs
ENV PORT=8080
EXPOSE 8080
CMD ["node", "agent.mjs"]
The bundle script (given/container/build.mjs) calls esbuild with a createRequire banner — a couple of transitive deps are CommonJS and require() Node builtins, which esbuild's ESM output otherwise stubs out. The result is one .mjs the run stage executes directly.
Two lines carry the contract. EXPOSE 8080 documents the port AgentCore will call. The CMD launches the server — and because lesson 02's entrypoint is guarded by import.meta.main, the bundle is the entry module, so Layer.launch(ServerLive) fires and binds 0.0.0.0:8080. The container is the lesson-02 server, nothing more.
given/container/Dockerfile
The image needs a home. We declare it as the first resource yielded inside the Alchemy.Stack generator — the same program that will grow the IAM role and the Runtime in lesson 05. Yielding a resource provisions it:
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import { Effect } from "effect";
export default Alchemy.Stack(
"EffectAgentCore",
{ providers: AWS.providers(), state: Alchemy.localState() },
Effect.gen(function* () {
const repo = yield* AWS.ECR.Repository("agent", {
imageTagMutability: "MUTABLE",
scanOnPush: true,
});
// repo.repositoryUri is where the image is tagged + pushed
return { repositoryUri: repo.repositoryUri };
}),
);
Two pieces of plumbing make this resolve. AWS.providers() registers every AWS resource provider, so yield* AWS.ECR.Repository(...) can find the provider that knows how to create an ECR repository. Alchemy.localState() stores resource state on disk under .alchemy/state, so a second run reconciles against what already exists instead of recreating it.
The yielded repo exposes typed outputs — the ones the rest of the deploy story consumes:
| Output | What it is |
|---|---|
repositoryUri | Where the image is tagged and pushed; what the Runtime references |
repositoryArn | The repository's ARN (used in IAM policies) |
repositoryName | The repository name (generated if you don't pass repositoryName) |
registryId | The owning registry's account id |
src/effect-agentcore/alchemy.run.ts · props + outputs from alchemy/AWS/ECR/Repository.ts — RepositoryProps/outputs · stack entrypoint shape from Alchemy v2 getting-started
Declaring the repository is only half the story: it's an empty registry. The image still has to be built from the Dockerfile and pushed to repo.repositoryUri. We do that with a second resource — Image — yielded right after the repository, so a single alchemy deploy builds and pushes the image with no manual docker step:
import { Layer } from "effect";
import { Image, ImageProvider } from "./given/container/Image.ts";
// providers: Layer.mergeAll(AWS.providers(), ImageProvider())
const repo = yield* AWS.ECR.Repository("agent", {
imageTagMutability: "MUTABLE",
scanOnPush: true,
});
const image = yield* Image("agent-image", {
repositoryUri: repo.repositoryUri,
dockerfile: "./given/container/Dockerfile",
tag: "latest",
});
// image.imageUri — the pushed `${repo.repositoryUri}:latest`, what the Runtime pulls
On reconcile the Image provider runs docker build against your local daemon (platform linux/arm64 — what AgentCore requires), exchanges an ECR authorization token, and docker pushes the tagged image. It's authored exactly like the AgentCore resources (lesson 10) — Resource<Self> + Provider.effect — over Alchemy's own Docker build/push helpers (alchemy/Bundle), the same ones the framework's AWS.ECS.Task uses. The one prerequisite is a running Docker daemon, because the build happens on your machine at deploy time.
So the full deploy story is one chain, all inside a single alchemy deploy:
repo.repositoryUri, returning image.imageUri.image.imageUri.
AWS.ECR.Repository provisions only the registry — the place a push lands. Yielding it gives you an empty repository and its repositoryUri; it does not run Docker. The build + push is the job of the separate Image resource. Both are reconciled by the same alchemy deploy: "no manual step" does not mean "one resource does everything" — it means the build step is itself a resource in the program, not a shell command you remember to run.
Why two resources instead of one? The repository is durable cloud state Alchemy reconciles (create-if-missing, tag, delete on destroy); the image is a local build artifact pushed into it. Keeping them distinct means a rebuild-and-push doesn't churn the repository resource, and repo.repositoryUri stays a stable value that both the Image build and the Runtime read. That's the same split AWS's own ECS.Task resource makes internally.
You could provision ECR with CDK and build the image in a separate CI job, then copy the resulting URI into the Runtime config by hand. The cost of that split is the copy-paste: an ARN or URI produced by one tool, pasted into another, drifting silently when either changes.
Because the repository declaration, the image build, and the agent code are one Effect program, image.imageUri is just a typed value in scope. In lesson 05 it flows directly into the Runtime resource:
const image = yield* Image("agent-image", { /* ... */ });
// ...lesson 05 adds, in the same generator:
// const runtime = yield* Runtime("Agent", {
// containerUri: image.imageUri, // ← typed, no copy-paste, built this deploy
// roleArn: role.roleArn,
// });
The image build, the repository that holds it, and the Runtime that consumes it share a codebase and a type. If the Image output shape changes, the Runtime line stops compiling — instead of failing at deploy time with a stale string. That's the payoff of keeping infra and agent in the same Effect program.
src/effect-agentcore/alchemy.run.ts
alchemy deploy
Lesson 04's checkpoint (solutions/04/alchemy.run.ts) is the first deployable stack: the ECR repository plus the Image build + push. Two prerequisites, because the build runs on your machine and the push goes to a real registry:
AWS_REGION — the credential chain + region are how AWS.providers() and the Image auth call reach the account.Image resource shells out to docker build / docker push locally, targeting linux/arm64 (what AgentCore Runtime requires).
You don't copy anything: alchemy deploy takes the run file as an argument. Invoke it through pnpm so it uses the repo-pinned alchemy (the one in node_modules), never a global install. Run it from the project root, point it at the checkpoint, and give it an isolated stage:
cd src/effect-agentcore # the project root — has package.json + .dockerignore
aws sso login # or export AWS_ACCESS_KEY_ID / SECRET / SESSION_TOKEN
export AWS_REGION=eu-central-1 # any ECR region
# preview first, then deploy just the lesson-04 stack to its own stage
pnpm alchemy deploy solutions/04/alchemy.run.ts --dry-run
pnpm alchemy deploy solutions/04/alchemy.run.ts --stage lesson04 --yes
On deploy you'll see the repository reconcile (create-if-missing), then the Image resource build the Dockerfile, exchange an ECR token, and push :latest. The stack returns repositoryUri and imageUri. Tear it down with the mirror command:
pnpm alchemy destroy solutions/04/alchemy.run.ts --stage lesson04 --yes
The Image build context is "." — your current directory, not the run file's directory — and the Dockerfile does COPY package.json ./ then COPY . .. Only the project root has package.json, src/, and the .dockerignore (which keeps node_modules/.alchemy/solutions out of the build). cd-ing into solutions/04/ and deploying there fails the build — no package.json in context. Staying at the root and passing the checkpoint's file gives you lesson 04's resource set with the correct build context.
(Plain pnpm deploy from the root deploys the full stack — every lesson's resource at once. The file-argument form above scopes the deploy to this one checkpoint.) This creates real AWS resources; ECR storage costs a few cents, so destroy the stage when you're done.
solutions/04/alchemy.run.ts · given/container/Image.ts · .dockerignore
The image is in ECR — built and pushed by the Image resource during alchemy deploy, no manual docker step. The lesson-02 server, unchanged, is now a container the cloud can pull — tagged at ${repo.repositoryUri}:latest — and both the repository and the image are reconciled state in your Alchemy stack. That's the deploy artifact AgentCore needs, sitting in the registry, with nothing yet running it.
Lesson 05 closes the loop: it adds the IAM execution role and the AgentCore.Runtime resource that pulls this image and runs it, with repo.repositoryUri flowing in as a typed value. From there the agent answers /invocations in the cloud, not just on localhost.