Describe the deployment artifact: the container
Outcome
By the end of this lesson you will read the one file that packages the capstone
agent as a container image, and you will be able to name every stage it goes
through — base image, dependency install, source copy, the port it exposes, and
the command it runs. You will understand the runtime contract the container must
satisfy (listen on port 8080, answer POST /invocations and GET /ping), and you
will build the image once on your own machine. All of this is free: reading a file
and running docker build locally touches no account and no cloud service.
This is the local track’s version of Lesson 8. The AWS track reads two CDK (Cloud Development Kit) stacks that describe cloud resources; the local track reads one Dockerfile that describes a container — a much smaller artifact, because there is no cloud to provision. Both lessons do the same job: read the deployment artifact before you deploy it.
Mental model
In a visual automation tool, pressing Publish takes the workflow you built and
makes it the real, running thing — you rarely think about what “the thing” actually
is. A container image makes that concrete: it is a sealed box holding your
code, the exact Python runtime it needs, and every locked dependency, built once
from a text recipe called a Dockerfile. docker build reads that recipe and
produces the box; docker run (Lesson 9) opens the box and runs it.
The Dockerfile is the artifact you review before you trust it — the same role
cdk synth’s output plays on the AWS track. Where the AWS track’s artifact is a
CloudFormation template describing cloud resources, the local track’s artifact is
a container image describing a runnable process. Both are short, reviewable
descriptions of something bigger they produce.
One detail matters and has no visual-tool equivalent: whatever runs inside the
container must speak a fixed contract so its host can supervise it — start it,
health-check it, and send it work. Amazon Bedrock AgentCore Runtime (the AWS
track’s host) and your own machine (the local track’s host, via plain docker run) both expect the exact same contract: a server on port 8080 that answers
POST /invocations for one agent turn and GET /ping for a health check. That
shared contract is why one image can serve both tracks — the box looks identical
to whoever is running it.
Where the analogy stops: publishing in a visual tool usually deploys to the vendor’s servers immediately. Building an image here changes nothing yet — it only produces a box sitting on your disk. Lesson 9 is where you actually run it.
Prerequisites and cost
- Lesson 0 complete: Docker installed (
docker --versionprints a version) and an OpenRouter API key obtained (you will use it in Lesson 9, not here). - Lesson 7 complete: the agent code is tested and ready to package.
Cost: free. Reading agent/Dockerfile costs nothing, and docker build only uses
your own machine’s CPU, memory, and disk to produce a local image — it contacts no
paid service and needs no credentials. (The base image and package downloads use
your internet connection, same as any other software install.)
The container’s job: one contract, two tracks
Whatever runs inside the container must satisfy the same runtime contract that
Amazon Bedrock AgentCore Runtime requires in the cloud: listen on port 8080, expose
POST /invocations for one agent turn, and GET /ping for a health check. You do
not write that HTTP server yourself. BedrockAgentCoreApp, imported in
agent/src/intake/runtime_app.py (read in full in Lesson 9), implements the
contract regardless of which track calls it — the name mentions AgentCore because
that library shipped for the AWS track, but nothing about the contract itself is
AWS-specific: a port and two HTTP routes are just a port and two HTTP routes. The
same server that answers AgentCore’s calls in the cloud answers your curl calls
on localhost next lesson.
Now read the recipe that builds the box around that server.
# Lesson 9: container image for Amazon Bedrock AgentCore Runtime.
#
# The runtime contract only requires that the container listen on port 8080 and
# expose POST /invocations and GET /ping; BedrockAgentCoreApp (in runtime_app.py)
# provides both. AgentCore Runtime expects linux/arm64 images, so build with:
# docker build --platform linux/arm64 -t intake-agent .
# ARM64 requirement, port, and paths follow
# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-http-protocol-contract.html
# Pinned base for reproducibility. Prefer pinning by digest in real deployments.
FROM python:3.12-slim-bookworm
# uv gives the same locked install inside the container as on a learner's machine.
# Pinned tag (see docs/research-log.md: uv 0.11.28).
COPY --from=ghcr.io/astral-sh/uv:0.11.28 /uv /uvx /bin/
WORKDIR /app
# Copy manifests first so dependency install is cached separately from source.
COPY pyproject.toml uv.lock .python-version ./
COPY src ./src
COPY data ./data
# --frozen: fail if the lockfile is stale. --no-dev: no test tooling in the image.
# --extra openrouter installs the OpenAI client so ONE image serves both tracks:
# the AWS track ignores it; the local track calls OpenRouter through it. (An
# optional extra is not installed by a bare `uv sync`, so it must be named here.)
RUN uv sync --frozen --no-dev --extra openrouter
EXPOSE 8080
# Runs app.run(), which starts the AgentCore HTTP server on port 8080.
CMD ["uv", "run", "--no-dev", "python", "-m", "intake.runtime_app"]
Every stage, named
Walk the file top to bottom:
- Base image —
FROM python:3.12-slim-bookworm. A small, pinned Debian-based Python 3.12 image. Pinning (rather thanpython:3.12orpython:latest) means the build is reproducible: the same tag produces the same base weeks later. - The
uvbinary —COPY --from=ghcr.io/astral-sh/uv:0.11.28 /uv /uvx /bin/. This copies theuvtool itself out of its own published image, pinned to the exact version this course uses elsewhere, so the container installs dependencies the identical way your own machine does. - Working directory —
WORKDIR /app. Every following instruction runs from/appinside the image. - Manifests first —
COPY pyproject.toml uv.lock .python-version ./. Copying only the dependency-defining files before the source code is a deliberate layer-ordering trick: Docker caches each instruction as a layer, and this layer only changes (forcing a slow reinstall) when a dependency changes — not every time you edit a line of Python. - Source and data —
COPY src ./srcandCOPY data ./data. Now the actual code and the local systems catalog land in the image, in their own layer. - Dependency install —
RUN uv sync --frozen --no-dev --extra openrouter. Three flags, three decisions:--frozenfails the build ifuv.lockis out of sync withpyproject.toml, instead of silently re-resolving different versions.--no-devskips the test toolchain (pytest), since the running container never needs it.--extra openrouteris the important one: it installs the OpenAI-compatible client Strands needs to call OpenRouter. This one flag is why a single image serves both tracks — the AWS track’s code never imports that client and simply ignores it being present, while the local track depends on it being there. Without--extra openrouter, this same image would only work for the AWS track.
- Exposed port —
EXPOSE 8080. Documents the contract port from the section above; it does not by itself publish the port (Lesson 9’sdocker run -pdoes that). - Entrypoint —
CMD ["uv", "run", "--no-dev", "python", "-m", "intake.runtime_app"]. The command the container actually runs when started. It launchesruntime_app.py, whoseapp.run()call starts the port-8080 server described above.
The one difference from the AWS track: no --platform flag
The Dockerfile’s own top comment says AgentCore Runtime expects linux/arm64
images, and the AWS track’s build command (Lesson 9 on that track) forces that
target with docker build --platform linux/arm64 .... That flag exists only
because AgentCore Runtime is a specific managed host with a fixed processor
architecture, and a learner’s laptop is very often a different one (Intel/AMD
amd64), so the AWS build has to cross-compile.
The local track has no such constraint. You are building the image to run on the
very machine you build it on, so a plain, unflagged docker build already targets
your machine’s native architecture — Apple Silicon builds an arm64 image, an
Intel or AMD machine builds an amd64 image, and either is exactly right for
docker run on that same machine. There is nothing to force and nothing to get
wrong here.
Steps
Run these from the agent/ folder.
-
Confirm Docker is available:
docker --version -
Build the image, tagging it
intake-agentso Lesson 9 can refer to it by name. No--platformflag — the build targets your machine’s own processor:docker build -t intake-agent .
Expected output
docker --version prints something like Docker version 27.x.x, build <hash> (your exact version will vary).
The build prints one numbered step per Dockerfile instruction, each ending
DONE (or CACHED on a repeat build), and finishes with a line naming the tag it
produced:
=> [1/6] FROM docker.io/library/python:3.12-slim-bookworm
=> [2/6] COPY --from=uv /uv /uvx /bin/
=> [3/6] WORKDIR /app
=> [4/6] COPY pyproject.toml uv.lock .python-version ./
=> [5/6] RUN uv sync --frozen --no-dev --extra openrouter
=> [6/6] COPY src ./src
=> exporting to image
=> => naming to docker.io/library/intake-agent
Confirm the image exists locally:
docker images intake-agent
which lists one row with REPOSITORY intake-agent and a TAG latest.
One common failure
Symptom: docker build fails at the uv sync --frozen ... step with an error
mentioning the lockfile is out of date, or docker: command not found before the
build even starts.
Diagnosis: two different causes with the same symptom shape. If the error
names uv.lock, pyproject.toml changed (a dependency was added or edited)
without regenerating the lockfile — --frozen refuses to guess. If docker
itself is missing, Docker was not installed in Lesson 0, or Docker Desktop /
the Docker daemon is not running.
Fix: for a stale lockfile, run uv lock from agent/ to regenerate it, then
rebuild. For a missing docker command, install Docker (or start Docker Desktop)
per Lesson 0, confirm with docker --version, then rebuild.
Why this works
The Dockerfile is short because most of what it does is delegate: uv handles the
exact, locked dependency install, and BedrockAgentCoreApp (used inside
runtime_app.py, read in full next lesson) handles the HTTP contract. What the
Dockerfile itself controls is narrow and reviewable — the base image, the install
flags, and the final command — which is why you can read the whole thing in one
sitting and know exactly what ships. The --extra openrouter flag is the single
line that makes one image cover both tracks, and skipping the --platform flag is
correct here specifically because the local track’s host and build machine are the
same computer, unlike the AWS track’s managed, fixed-architecture runtime.
Verify it yourself
The checkpoint is: you can name every stage of the Dockerfile and explain the port-8080 contract.
- Without looking back at the file, list the Dockerfile’s stages in order (base
image,
uvcopy, working directory, manifest copy, source/data copy, dependency install, expose, command) and check yourself against the numbered list above. - Explain in one sentence what
POST /invocationsandGET /pingare for, and name the library that implements them (BedrockAgentCoreApp). - Explain why
docker build -t intake-agent .needs no--platformflag on the local track, when the AWS track’s equivalent command requires one. - Confirm
docker images intake-agentlists the image you just built.
Naming every stage, explaining the contract, and seeing the built image are this lesson’s checkpoint — you now know exactly what the box you are about to run contains.
Cleanup
Nothing billable or persistent was created — a local image sitting on your disk costs nothing to keep. If you want to reclaim the disk space before continuing, you can remove it (Lesson 9 rebuilds it anyway):
docker rmi intake-agent
Otherwise, leave the image in place — Lesson 9 runs it next.

