Dockerize and Deploy an AWS Agent
Build, containerize, and continuously deploy an AWS agent to ECS Fargate.
Introduction
30 Second Summary
Cloud operations questions often arrive when someone needs a reliable answer quickly. Checking the wrong account or overlooking a running server can turn a simple request into a risky guess.
In this project, you will build a read-only AWS cloud operations assistant with the Strands Agents SDK behind a FastAPI endpoint. The assistant uses Amazon Nova 2 Lite through Amazon Bedrock to answer questions about your live environment.
What You'll Build
You will submit an operations question in the interactive API page and receive a concise answer drawn from your live AWS identity and EC2 inventory.
By the end of this project, you'll have:
- A working operations API where you submit a natural-language question at http://127.0.0.1:8000/docs and receive an answer from the agent.
- A live caller identity check that reports the active AWS account plus an EC2 inventory showing up to 25 visible instances.
- A non-root Docker container that receives temporary AWS credentials only when it starts.
- Secret Mission: Harden the container with a read-only root filesystem, temporary writable storage, and no-new-privileges.
Do I need anything before starting?
You need an AWS account with an identity allowed to invoke Amazon Nova 2 Lite and describe EC2 instances.
You also need sudo access on an internet-connected Linux workstation.
Before We Start
This checkpoint turns the project into a concrete operations goal before any hands-on work begins. Choose an AWS question that your read-only assistant should answer for an operator.
Prepare Your Linux Workstation
Your assistant needs a consistent runtime from uv with Python. An authenticated AWS session lets it reach live account data.
Docker provides the runtime boundary used later in the project. This step installs each prerequisite separately so every layer is proven before application code arrives.
In this step, get ready to:
- Install uv 0.12.17 with Python 3.12.
- Authenticate AWS CLI 2.36.49 or later through a temporary session.
- Run Docker Engine as your current Linux user.
Install uv and Python 3.12
uv manages the Python runtime for this project. Pinning uv to 0.12.17 gives you the same workflow throughout the build.
- Check whether uv is already installed by running this command:
uv --version
What Does This Check Do?
This asks the uv launcher to print its installed version. Pick the tab that matches what your terminal shows.
✔️ I see uv 0.12.17
Your uv launcher already matches the project pin.
- Install Python 3.12 through uv by running this command:
uv python install 3.12
What Does This Command Do?
uv downloads a managed Python 3.12 runtime. Later project commands can select this runtime without depending on the system Python installation.
ⓧ I see another version
The pinned installer aligns uv with the version used throughout this project.
- Install uv 0.12.17 with Python 3.12 by running these commands:
curl -LsSf https://astral.sh/uv/0.12.17/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv python install 3.12
What Do These Commands Do?
- The first command downloads the pinned uv installer over HTTPS.
- The second command adds $HOME/.local/bin to the current shell's PATH.
- The final command installs a uv-managed Python 3.12 runtime.
ⓧ Command not found
Your shell cannot find uv yet. The pinned installer places it under your home directory.
- Install uv 0.12.17 with Python 3.12 by running these commands:
curl -LsSf https://astral.sh/uv/0.12.17/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv python install 3.12
What Do These Commands Do?
- The installer places uv in $HOME/.local/bin.
- The export makes uv available in the current terminal.
- The final command installs Python 3.12 through uv.
Why Pin the Launcher?
A pinned launcher keeps environment creation consistent across workstations. This removes tool-version drift before the agent dependencies enter the project.
Before you verify this layer, what two version numbers do you expect the terminal to report?
- Verify uv with Python 3.12 by running these commands:
uv --version
uv run --python 3.12 python --version
What Do These Checks Prove?
The first check confirms the uv launcher version. The second check asks uv to run the selected Python runtime.
You should see uv 0.12.17 followed by Python 3.12. That is the first workstation layer locked in.
Still Missing uv or Python?
- Confirm that $HOME/.local/bin appears on your current shell's PATH.
- Run the pinned installer again if the uv version differs from 0.12.17.
- Run the Python installation command again if uv cannot locate Python 3.12.
Still stuck? Help me troubleshoot my uv and Python setup.
Install and authenticate the AWS CLI
The AWS CLI supplies temporary credentials through a browser-based sign-in. The agent can later use that authenticated session through the standard AWS credential chain.
- Check the installed AWS CLI version by running this command:
aws --version
What Does This Check Do?
This prints the AWS CLI version available to your current shell. The browser login used here requires AWS CLI 2.32.0 or later.
✔️ I see version 2.36.49 or higher
Your AWS CLI version supports the browser login used in this project.
ⓧ I see an older version
The current Linux installer updates the AWS CLI for your user.
- Install the current AWS CLI by running this command:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash
What Does This Command Do?
The command downloads the official Linux installation script. It runs the script for the current user.
- Confirm the updated AWS CLI version by running this command:
aws --version
What Should the Version Show?
The reported version should be 2.36.49 or later. That version supports the temporary browser session used next.
ⓧ Command not found
The AWS CLI is not available to your current shell yet.
- Install the current AWS CLI by running this command:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash
What Does This Command Do?
The command downloads the official Linux installation script. It installs the AWS CLI for the current user.
- Confirm the AWS CLI installation by running this command:
aws --version
What Should the Version Show?
The reported version should be 2.36.49 or later. Your shell can now start the browser login.
Authentication can feel sensitive. This browser flow keeps secret values out of the project files.
The login command opens a browser-based sign-in flow. Your terminal waits while you complete it.
- Start the temporary AWS browser session by running this command:
aws login
What Does This Command Do?
The AWS CLI starts a browser sign-in for temporary credentials. The resulting session becomes available to AWS commands in your current environment.
- Complete the sign-in steps shown in your browser.
Before you test the session, what account details do you expect AWS to return for the active identity?
- Verify the temporary AWS session by running this command:
aws sts get-caller-identity
What Does This Identity Check Prove?
AWS Security Token Service returns the identity attached to the active credentials. The response includes UserId. It also includes Account plus Arn.
You should see a JSON identity response for your signed-in account. You have now proved that the workstation can authenticate to AWS without placing credentials in a source file.
Identity Check Not Working?
- Repeat the browser login if the temporary session expired before the identity check.
- Confirm that the browser reported a completed sign-in before returning to the terminal.
- Check that your AWS identity has permission to request its caller identity.
Need another pair of eyes? Help me debug my AWS CLI browser session.
Install Docker Engine
Docker Engine runs containers through a daemon on your Linux workstation. The convenience installer used here is intended for a testing or development environment.
Treat Docker Group Access Carefully
Membership in the Docker group grants root-level privileges through the Docker daemon. Enable it only on a trusted development workstation.
- Check whether Docker is already usable by running this command:
docker run hello-world
What Does This Check Do?
Docker attempts to obtain the small hello-world image. It then starts a container that prints a confirmation message.
✔️ I see the confirmation message
Docker Engine is installed. Your current user can already communicate with its daemon without sudo.
ⓧ I see a permissions error
Docker is present. Your current user still needs access through the Docker group.
- Activate Docker group access in a new shell by running these commands:
sudo usermod -aG docker $USER
newgrp docker
What Do These Commands Do?
The first command adds your user to the Docker group. The second command starts a shell with the new group membership active.
- Verify Docker access by running this command:
docker run hello-world
What Should Happen Now?
Docker should print its confirmation message without asking for sudo. That proves your current shell has access to the daemon.
ⓧ Command not found
Docker Engine needs to be installed before your user can join its Docker group.
- Download the development installer by running this command:
curl -fsSL https://get.docker.com -o get-docker.sh
What Does This Command Do?
The command downloads Docker's convenience installer into get-docker.sh. This keeps the installer available as a local file before it runs.
- Install Docker Engine for this development lab by running this command:
sudo sh get-docker.sh
What Does This Command Do?
The script detects the supported Linux distribution. It installs the latest stable Docker Engine packages for that environment.
- Activate Docker group access in a new shell by running these commands:
sudo usermod -aG docker $USER
newgrp docker
What Do These Commands Do?
The first command adds your user to the Docker group. The second command activates that membership in a new shell.
- Verify Docker Engine by running this command:
docker run hello-world
What Should Docker Show?
Docker downloads the hello-world image if needed. The container prints a confirmation message when the daemon is working.
- Remove the downloaded installer file by running this command:
rm get-docker.sh
Why Remove the Installer?
Docker Engine is already installed. Removing the downloaded script leaves the workstation without an unnecessary installer file.
Before the final check, which command do you expect to prove each workstation layer is ready?
- Verify the complete workstation setup by running these commands:
uv --version
uv run --python 3.12 python --version
aws sts get-caller-identity
docker run hello-world
What Does This Final Check Cover?
- The uv check confirms the pinned launcher is available on your current PATH.
- The Python check confirms uv can run Python 3.12.
- The identity check confirms the temporary AWS session is active.
- The Docker check confirms your user can run containers without sudo.
The first two checks should report uv 0.12.17 followed by Python 3.12. The identity check should return your AWS caller details.
The Docker check should print its confirmation message. Your workstation now has every runtime layer needed for the assistant.
One of the Final Checks Failed?
- Return to the uv outcome tab if either runtime version is missing.
- Repeat the browser login if the AWS session has expired.
- Confirm that your current shell inherited Docker group membership if Docker still requires sudo.
Still blocked? Help me identify which workstation layer is failing.
✔️ Awesome, I've got everything!
Your Python runtime, temporary AWS session, and Docker runtime are ready for the application build.
ⓧ I'd like to double check the full code
Your Linux workstation should match this exact state:
- uv 0.12.17 with Python 3.12 installed.
- AWS CLI 2.36.49 or later installed with an authenticated temporary AWS session.
- Docker Engine installed for the current user without sudo.
Your Linux workstation is ready to run Python, authenticate to AWS, and start containers. Next up, you'll launch the first working agent API.
Launch the First Agent API
The workstation is ready. Your assistant now needs an HTTP interface that an operator can call.
FastAPI creates that interface. The Strands Agents SDK sends each question to Amazon Nova 2 Lite through Amazon Bedrock.
In this step, get ready to:
- Create a Python 3.12 project environment with pinned packages.
- Build a FastAPI endpoint backed by a fresh Strands agent.
- Prove the API returns an Amazon Nova 2 Lite response.
Why Strands for the agent loop?
An agent loop sends a prompt to a model. It also manages the model response.
Strands exposes this loop without extra orchestration code. That keeps this first API focused on the request flow.
Create the project environment
Pinned dependencies give every project run the same library versions. A virtual environment keeps those packages inside cloud-ops-assistant.
- Create the cloud-ops-assistant folder from your Desktop by running these commands:
cd ~/Desktop
mkdir cloud-ops-assistant
cd cloud-ops-assistant
pwd
What do these commands do?
- The first command places the project on your Desktop.
- The next command creates the cloud-ops-assistant directory.
- The final command prints your current location so you can confirm where later files are saved.
You should see a path ending in cloud-ops-assistant.
- Open the cloud-ops-assistant folder in your preferred Linux code editor.
- Create requirements.txt inside the cloud-ops-assistant folder.
- Add the pinned dependency list to requirements.txt by copying this code:
strands-agents==1.56.0
fastapi==0.141.1
uvicorn==0.53.0
boto3==1.43.98
pydantic==2.13.5
What are these dependencies?
- Strands Agents SDK 1.56.0 provides the agent loop.
- FastAPI 0.141.1 validates requests through Pydantic 2.13.5.
- Uvicorn 0.53.0 serves the FastAPI application.
- Boto3 1.43.98 supports the AWS operations added later.
- Save requirements.txt.
- Create the Python 3.12 virtual environment by running:
uv venv --python 3.12
What does this command do?
This command creates .venv with the Python 3.12 installation managed by uv. The environment gives the project an isolated package directory.
You should see uv confirm that it created .venv with Python 3.12.
- Install the exact versions from requirements.txt by running:
uv pip sync requirements.txt
Why sync the requirements?
The sync operation makes .venv match requirements.txt. This prevents accidental upgrades from changing the project underneath you.
The installation should finish successfully before your terminal prompt returns. Your project now has the complete dependency set for this API.
Seeing a dependency error?
- Confirm that your terminal is still inside the cloud-ops-assistant directory.
- Check that requirements.txt matches the pinned list exactly.
- Confirm that the earlier environment command created .venv before retrying the sync.
Still stuck? Help me diagnose my uv dependency installation.
✔️ Awesome, I've got everything!
Your Python 3.12 environment now contains every pinned dependency required by the agent API.
ⓧ I'd like to double check the full code
Compare your saved requirements.txt with this complete file.
strands-agents==1.56.0
fastapi==0.141.1
uvicorn==0.53.0
boto3==1.43.98
pydantic==2.13.5
Build the agent and health route
The API needs request models before it can accept questions. It also needs a model configuration that every request can use to create a fresh agent.
- Create app.py inside the cloud-ops-assistant folder.
- Add the imports, model settings, application metadata, and request models by copying this code into app.py.
from fastapi import FastAPI
from pydantic import BaseModel
from strands import Agent
from strands.models import BedrockModel
AWS_REGION = "us-east-1"
MODEL_ID = "us.amazon.nova-2-lite-v1:0"
app = FastAPI(
title="Read-Only AWS Cloud Ops Assistant",
version="1.0.0",
)
class AskRequest(BaseModel):
question: str
class AskResponse(BaseModel):
answer: str
What does this code do?
- The imports provide the web application, validated data models, agent, and Bedrock model adapter.
- AWS_REGION keeps model traffic in us-east-1.
- MODEL_ID selects us.amazon.nova-2-lite-v1:0.
- AskRequest requires every incoming request to contain a text question.
- AskResponse gives every successful response an answer field.
- Save app.py.
- Switch back to the terminal inside cloud-ops-assistant.
- Check the application foundation by starting Uvicorn with this command:
uv run uvicorn app:app --reload --host 127.0.0.1 --port 8000
What does this command do?
Uvicorn imports the app object from app.py. Reload mode watches the local source file during development.
The host setting limits access to your workstation. Port 8000 becomes the local entry point for the API.
You should see startup output showing that Uvicorn is serving the application at http://127.0.0.1:8000.
- Open http://127.0.0.1:8000/docs in your browser.
You should see interactive documentation titled Read-Only AWS Cloud Ops Assistant.
- Stop Uvicorn by pressing Ctrl+C in the terminal.
Does the documentation fail to load?
- Confirm that the terminal shows Uvicorn startup output instead of a returned command prompt.
- Check that app.py is saved inside cloud-ops-assistant.
- Compare each import with the dependency names in requirements.txt.
Need another pair of eyes? Help me find why my first FastAPI documentation page is unavailable.
- Add build_agent() below AskResponse in app.py by copying this code:
def build_agent() -> Agent:
model = BedrockModel(
model_id=MODEL_ID,
region_name=AWS_REGION,
streaming=False,
temperature=0.2,
max_tokens=400,
)
return Agent(model=model)
How is the model configured?
- model_id selects the Nova 2 Lite inference profile stored in MODEL_ID.
- streaming=False waits for one complete model result.
- temperature=0.2 keeps responses focused.
- max_tokens=400 caps the model output for this request.
- build_agent() returns a new agent whenever the API calls it.
- Save app.py.
- Verify the new function imports successfully by starting Uvicorn again:
uv run uvicorn app:app --reload --host 127.0.0.1 --port 8000
What does this check prove?
A successful startup confirms that Python can import BedrockModel and parse build_agent(). The model remains idle until a request reaches /ask.
You should see the normal Uvicorn startup output with no import or syntax failure.
- Stop Uvicorn by pressing Ctrl+C.
- Add the health route below build_agent() by copying this code:
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
What does the health route prove?
The /health route confirms that the web process can accept a request. It avoids a model call so you can test the local API separately.
- Save app.py.
- Start Uvicorn by running:
uv run uvicorn app:app --reload --host 127.0.0.1 --port 8000
Why run the server again?
This run loads the new health route. It gives you a fast check before the first model request.
- Open http://127.0.0.1:8000/health in your browser.
You should see {"status": "ok"}. This confirms the local FastAPI process is accepting requests.
- Stop Uvicorn by pressing Ctrl+C.
Is the health check unavailable?
- Confirm that the health decorator contains /health.
- Check that the terminal remains occupied by the Uvicorn process while you open the URL.
- Save app.py before restarting the server.
Still blocked? Help me debug my FastAPI health route.
Add the ask endpoint and test the agent
The final route connects validated HTTP input to the model. Each request builds a fresh agent so separate callers do not share conversation history.
- Add the /ask route below the health route in app.py by copying this code:
@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
result = build_agent()(
request.question,
limits={
"turns": 4,
"output_tokens": 600,
"total_tokens": 4000,
},
)
return AskResponse(answer=str(result))
How does the request flow work?
- The route accepts a validated AskRequest body.
- build_agent() creates a separate agent for the current request.
- turns limits the number of agent-loop turns to 4.
- output_tokens limits one model output to 600 tokens.
- total_tokens caps the entire invocation at 4000 tokens.
- AskResponse converts the agent result into the API response shape.
- Save app.py.
✔️ Awesome, I've got everything!
Your saved application now contains the health route and the first model-backed ask route.
ⓧ I'd like to double check the full code
Compare your saved app.py with this complete version.
from fastapi import FastAPI
from pydantic import BaseModel
from strands import Agent
from strands.models import BedrockModel
AWS_REGION = "us-east-1"
MODEL_ID = "us.amazon.nova-2-lite-v1:0"
app = FastAPI(
title="Read-Only AWS Cloud Ops Assistant",
version="1.0.0",
)
class AskRequest(BaseModel):
question: str
class AskResponse(BaseModel):
answer: str
def build_agent() -> Agent:
model = BedrockModel(
model_id=MODEL_ID,
region_name=AWS_REGION,
streaming=False,
temperature=0.2,
max_tokens=400,
)
return Agent(model=model)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
result = build_agent()(
request.question,
limits={
"turns": 4,
"output_tokens": 600,
"total_tokens": 4000,
},
)
return AskResponse(answer=str(result))
- Start the completed API by running:
uv run uvicorn app:app --reload --host 127.0.0.1 --port 8000
What starts the final test?
Uvicorn loads both routes from the saved application. The server keeps this terminal occupied until you stop it.
You should see startup output confirming that the API is available at http://127.0.0.1:8000.
- Open http://127.0.0.1:8000/docs in your browser.
- Expand POST /ask in the interactive documentation.
- Use the request control to enable body editing.
- Enter this introductory question in the question field:
In one sentence, what should a cloud operations assistant do?
Why start with this question?
This prompt tests the complete HTTP-to-model path without requiring account-specific tools. A concise answer proves the base agent can receive a request and return model output.
This test sends one short prompt through on-demand inference. Billing is based on the input tokens and output tokens processed.
Before you submit the request, do you expect the response to follow the one-sentence instruction?
- Submit the request from the interactive documentation.
The first model request can pause while it crosses the network to Amazon Bedrock. A brief wait is expected.
You should see a successful response containing a concise answer from Amazon Nova 2 Lite. That completes your first working agent loop through an HTTP API.
- Stop the local server by pressing Ctrl+C in the terminal.
Does the model request fail?
- Confirm that the temporary AWS session from the previous step is still active.
- Check that your AWS identity can invoke Amazon Nova 2 Lite through Amazon Bedrock.
- Inspect the Uvicorn terminal for a message mentioning authentication, permissions, or model access.
- Refresh the browser documentation after every server restart.
Need help with the failure? Help me diagnose my first Strands request to Amazon Bedrock.
Your first agent API can now accept a validated question and return an Amazon Nova 2 Lite response. Next, you will give the agent narrowly scoped tools for live AWS facts.
Add Read-Only AWS Tools
Your FastAPI endpoint already turns a general cloud operations question into an Amazon Bedrock response. It still cannot inspect the AWS account behind your temporary session.
Account-specific answers need narrow custom tools with least privilege outputs. You'll add one tool for caller identity plus one for a capped EC2 inventory.
In this step, get ready to:
- Add an AWS identity tool with a minimal response.
- Add an EC2 inventory tool capped at 25 instances.
- Register both tools with the agent's read-only policy.
Add the AWS identity tool
AWS Security Token Service exposes the identity associated with the active credentials. Boto3 lets Python call that identity operation through the existing credential chain.
- In app.py, select the import block at the top of the file.
- Replace the selected block with this code:
from typing import Any
import boto3
from fastapi import FastAPI
from pydantic import BaseModel
from strands import Agent, tool
from strands.models import BedrockModel
Why These Imports?
- The Any type describes values inside the EC2 result dictionary.
- The boto3 package creates clients for the AWS services used by your tools.
- The tool decorator turns a typed Python function into an agent tool.
- In app.py, locate the closing line of the AskResponse class.
- Add the identity tool below the AskResponse class by copying this code:
@tool
def get_aws_identity() -> dict[str, str]:
"""Return the AWS account ID and caller ARN for the active credentials."""
identity = boto3.client("sts", region_name=AWS_REGION).get_caller_identity()
return {
"account": identity["Account"],
"arn": identity["Arn"],
}
What Does This Tool Return?
- The @tool decorator exposes the function's typed input plus its result shape to the agent.
- The service client uses AWS_REGION plus the active credential chain.
- The get_caller_identity() operation reads the current caller details.
- The returned dictionary keeps only the account ID plus caller ARN.
- Save app.py.
- Switch back to the terminal from earlier.
- Confirm the updated file still loads by starting the local server with this command:
uv run uvicorn app:app --reload --host 127.0.0.1 --port 8000
What Does This Check Prove?
Uvicorn imports app.py before it starts the API. A successful startup proves that Python can load the new imports plus the identity tool.
You'll see startup output showing that the local API is running. That first edit held: your API still starts with the new dependencies in place.
Server Stops During Startup?
- Check that the import block in app.py matches the replacement code exactly.
- Confirm that get_aws_identity sits outside the AskResponse class.
Still stuck? Help me diagnose why app.py fails after adding the AWS identity tool.
- Stop the local server by pressing Ctrl+C.
Summarize visible EC2 instances
Amazon EC2 can describe instances visible to the active identity in a selected Region. Your tool caps each result at MaxResults=25 to keep the agent's context compact.
The function is longer than one teaching snippet. You'll add both parts before saving so app.py remains runnable.
- In app.py, place your cursor one blank line below the closing brace of get_aws_identity.
- Keep app.py unsaved until the second snippet is in place.
- Paste the first part of the EC2 tool:
@tool
def list_ec2_instances(region: str = AWS_REGION) -> dict[str, Any]:
"""List up to 25 EC2 instances in an AWS Region without changing them.
Args:
region: AWS Region code to inspect, such as us-east-1.
"""
response = boto3.client("ec2", region_name=region).describe_instances(
MaxResults=25
)
instances: list[dict[str, str]] = []
How Does the Inventory Start?
- The region parameter defaults to AWS_REGION.
- The EC2 client performs a read-only description request for up to 25 instances.
- The empty instances list gives the function a compact destination for the fields the agent needs.
- Paste this continuation directly after the instances declaration:
for reservation in response.get("Reservations", []):
for instance in reservation.get("Instances", []):
name = next(
(
tag["Value"]
for tag in instance.get("Tags", [])
if tag.get("Key") == "Name"
),
"unnamed",
)
instances.append(
{
"id": instance["InstanceId"],
"name": name,
"state": instance["State"]["Name"],
"type": instance["InstanceType"],
"availability_zone": instance["Placement"]["AvailabilityZone"],
}
)
return {
"region": region,
"instance_count": len(instances),
"instances": instances,
"result_limit": 25,
}
How Does the Inventory Stay Compact?
- The nested loops collect instances from every reservation returned in the response.
- The Name tag becomes unnamed when an instance has no Name tag.
- Each result keeps the instance ID plus four operational fields.
- The final dictionary reports the inspected Region plus the result limit.
- Save app.py.
- Return to the terminal from earlier.
- Confirm the completed EC2 tool loads by starting the local server again:
uv run uvicorn app:app --reload --host 127.0.0.1 --port 8000
What Does This Startup Check?
This startup imports the completed list_ec2_instances function. A running server confirms that its indentation plus type annotations are valid.
You'll see the local API start successfully again. The complete inventory tool now loads without changing any AWS resources.
Seeing a Python Syntax Error?
- Confirm that the second snippet begins inside list_ec2_instances with four spaces before the first loop.
- Check that the final closing brace aligns with the function's return statement.
- Compare every parenthesis in the name = next( section with the supplied snippet.
Need another pair of eyes? Help me fix the structure of list_ec2_instances.
- Stop the local server by pressing Ctrl+C.
Connect the tools to the agent
The current Strands Agents SDK setup gives the model no account-specific operations. Registering both functions lets the agent choose a tool when a question needs live AWS data.
A system prompt defines the operating boundary for every request. It tells the assistant to use tools for account facts plus report errors plainly.
- In app.py, locate build_agent().
- Find its current return line:
return Agent(model=model)
What Is the Current Gap?
This version gives the agent only its model. It has no registered path to inspect the current caller or visible EC2 instances.
- Replace the current return line with this agent configuration:
return Agent(
model=model,
tools=[get_aws_identity, list_ec2_instances],
system_prompt=(
"You are a cautious AWS cloud operations assistant. "
"Use tools for account-specific facts. You are read-only and must never "
"claim that you changed a resource. If a tool returns an error or no "
"resources, say so plainly. Keep answers under 180 words."
),
)
How Does the Agent Stay Read-Only?
- The tools list exposes only identity lookup plus EC2 description.
- The system prompt requires tools for facts tied to the current account.
- The system prompt prohibits claims that a resource changed.
- The response limit keeps each operational summary concise.
- Save app.py.
- Switch back to the terminal from earlier.
- Start the updated API by running:
uv run uvicorn app:app --reload --host 127.0.0.1 --port 8000
What Does This Command Start?
Uvicorn starts the updated FastAPI application on the local interface. The reload option keeps the development server in sync with saved changes.
You'll see startup output showing that the API is available at http://127.0.0.1:8000.
- Return to the browser tab from the previous step.
- Go to http://127.0.0.1:8000/docs.
- Expand POST /ask in the interactive API page.
- Enable editing for the request body.
- Replace the request body's question value with Which AWS account am I using, and what EC2 instances are visible in us-east-1?.
Before you send the request, consider whether the agent can return live caller data plus an EC2 result.
- Send the request.
You'll see the live caller ARN plus an EC2 summary for us-east-1. An account with no visible instances produces an explicit empty result.
That's the live-data bridge working: your assistant now grounds its response in your AWS account.
Live AWS Request Fails?
- Refresh the temporary browser-authenticated AWS CLI session from earlier if the identity tool cannot find active credentials.
- Confirm that the current identity is allowed to describe EC2 instances in us-east-1 if only the inventory tool fails.
- Treat an explicit empty inventory as a successful result when the account has no visible instances.
Still blocked? Help me troubleshoot why the read-only AWS tools fail through POST /ask.
✔️ Awesome, I've got everything!
Your saved app.py now contains both read-only tools plus the agent policy that uses them.
ⓧ I'd like to double check the full code
- Compare your saved app.py with this complete reference:
from typing import Any
import boto3
from fastapi import FastAPI
from pydantic import BaseModel
from strands import Agent, tool
from strands.models import BedrockModel
AWS_REGION = "us-east-1"
MODEL_ID = "us.amazon.nova-2-lite-v1:0"
app = FastAPI(
title="Read-Only AWS Cloud Ops Assistant",
version="1.0.0",
)
class AskRequest(BaseModel):
question: str
class AskResponse(BaseModel):
answer: str
@tool
def get_aws_identity() -> dict[str, str]:
"""Return the AWS account ID and caller ARN for the active credentials."""
identity = boto3.client("sts", region_name=AWS_REGION).get_caller_identity()
return {
"account": identity["Account"],
"arn": identity["Arn"],
}
@tool
def list_ec2_instances(region: str = AWS_REGION) -> dict[str, Any]:
"""List up to 25 EC2 instances in an AWS Region without changing them.
Args:
region: AWS Region code to inspect, such as us-east-1.
"""
response = boto3.client("ec2", region_name=region).describe_instances(
MaxResults=25
)
instances: list[dict[str, str]] = []
for reservation in response.get("Reservations", []):
for instance in reservation.get("Instances", []):
name = next(
(
tag["Value"]
for tag in instance.get("Tags", [])
if tag.get("Key") == "Name"
),
"unnamed",
)
instances.append(
{
"id": instance["InstanceId"],
"name": name,
"state": instance["State"]["Name"],
"type": instance["InstanceType"],
"availability_zone": instance["Placement"]["AvailabilityZone"],
}
)
return {
"region": region,
"instance_count": len(instances),
"instances": instances,
"result_limit": 25,
}
def build_agent() -> Agent:
model = BedrockModel(
model_id=MODEL_ID,
region_name=AWS_REGION,
streaming=False,
temperature=0.2,
max_tokens=400,
)
return Agent(
model=model,
tools=[get_aws_identity, list_ec2_instances],
system_prompt=(
"You are a cautious AWS cloud operations assistant. "
"Use tools for account-specific facts. You are read-only and must never "
"claim that you changed a resource. If a tool returns an error or no "
"resources, say so plainly. Keep answers under 180 words."
),
)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
result = build_agent()(
request.question,
limits={
"turns": 4,
"output_tokens": 600,
"total_tokens": 4000,
},
)
return AskResponse(answer=str(result))
How to Compare the File
Check the imports first. Follow the file downward through both tools plus build_agent() so indentation differences are easy to spot.
Your assistant now grounds its answers in live read-only AWS data. Next, you'll package that working API into a non-root Docker image.
Build the Non-Root Container
Your read-only AWS tools now answer account-specific questions through FastAPI. Packaging that service lets the same pinned application run beyond your workstation.
A Docker container isolates the service from the host. In this step, you'll build a non-root image before testing whether the host can reach it.
In this step, get ready to:
- Define the non-root container image.
- Keep local development files out of Docker's build input.
- Test host access to a detached container.
Create the container files
The Dockerfile describes the image from its Python base through its startup command. It also switches the service to appuser before Uvicorn starts.
- Create a file named Dockerfile inside the cloud-ops-assistant directory using the editor where you worked on app.py.
You should see an empty Dockerfile beside app.py in your editor's file list.
- Fill Dockerfile with the image definition below:
FROM public.ecr.aws/docker/library/python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
How does this image stay non-root?
- The FROM instruction provides the Python 3.12 runtime in a slim base image.
- The first COPY instruction adds requirements.txt. The following RUN instruction installs the pinned dependencies.
- The second COPY instruction adds only app.py. Your AWS credentials never enter the image.
- The USER appuser instruction makes the remaining process run without root privileges.
- The CMD instruction starts uvicorn with two workers on container port 8000.
- Save Dockerfile.
Your editor should show Dockerfile as a saved file with no extension.
File named Dockerfile.txt?
- Check the file name for a hidden .txt suffix.
- Rename Dockerfile.txt to Dockerfile if your editor added the suffix.
Still unsure about the file? Help me check whether my Dockerfile is named and saved correctly.
A Docker build context contains the files available during an image build. The .dockerignore file removes local development data that the image does not need.
- Create a file named .dockerignore inside the cloud-ops-assistant directory using your editor's file sidebar.
You should see an empty .dockerignore beside Dockerfile.
- Fill .dockerignore with the exclusions below:
.venv
__pycache__
*.pyc
.git
What do these exclusions remove?
- The .venv entry keeps your workstation's virtual environment out of the build input.
- The __pycache__ entry removes Python cache directories.
- The *.pyc pattern removes compiled Python cache files.
- The .git entry keeps local repository history out of the build input.
- Save .dockerignore.
Your editor should now list both container files beside the existing Python files.
Are local files still included?
- Check that .dockerignore starts with a full stop.
- Confirm that each exclusion appears on its own line.
Need another pair of eyes? Help me check my .dockerignore entries.
✔️ Awesome, I've got everything!
Your saved container files now define the complete image build.
ⓧ I'd like to double check the full code
- Compare your Dockerfile with this complete reference:
FROM public.ecr.aws/docker/library/python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Dockerfile reference
This reference contains the complete image definition used in this step.
- Compare your .dockerignore with this complete reference:
.venv
__pycache__
*.pyc
.git
Build exclusions reference
This reference contains every local path excluded from the build input.
Build the Docker image
An image packages the application code with its pinned runtime dependencies. The tag cloud-ops-assistant:1.0 gives this build a stable local name.
The first build can pause while Docker downloads the base image. Quiet output during a download does not mean the build has stalled.
- Build the image from the cloud-ops-assistant directory by running:
docker build -t cloud-ops-assistant:1.0 .
What does this command do?
- The docker build command processes the instructions in Dockerfile.
- The -t option assigns the name cloud-ops-assistant with the tag 1.0.
- The final . selects the current directory as the build input.
- Wait for the build command to return to your shell prompt.
- Confirm that the final build status reports creation of cloud-ops-assistant:1.0.
That build gives you a portable service image that runs as appuser.
Did the image build fail?
- Confirm that Docker Engine is running on your Linux workstation.
- Compare the file names in Dockerfile with requirements.txt and app.py.
- Check that your terminal is inside the cloud-ops-assistant directory.
Still blocked? Help me troubleshoot my Docker image build.
Test the container boundary
Your local Uvicorn process must stop before this test. Otherwise, a response on host port 8000 could come from the local server instead of the container.
- Switch back to the terminal running the local Uvicorn process.
- Press Ctrl+C to stop the local server.
Your shell prompt should return after the local server stops.
- Start the image as a detached container by running:
docker run --rm --name cloud-ops-assistant -d cloud-ops-assistant:1.0
What does this command do?
- The -d option runs the container in the background.
- The --name cloud-ops-assistant option assigns a reusable container name.
- The --rm option removes the container automatically after it stops.
- The final value selects the cloud-ops-assistant:1.0 image you just built.
- Confirm the detached container is running by executing:
docker ps
What does this check show?
The command lists containers that are currently running. It lets you separate container startup from host network access.
You should see cloud-ops-assistant in the running container list.
Container missing from the list?
- Check the output shown immediately after the container start command for a startup failure.
- Compare the CMD instruction in your Dockerfile with the full-code reference above.
Need help reading the failure? Help me find why my container stopped after startup.
Before you try the health endpoint, do you think a running container automatically makes its API reachable from the host?
- Enter http://127.0.0.1:8000/health in your browser's address bar.
Your browser should report that it cannot connect to the service. This failure is expected.
Why is this failure useful?
The running container proves the application process started. The failed host request proves that container port 8000 has not been published across the network boundary.
The EXPOSE 8000 instruction records the intended container port. Host access requires an explicit port publication when the container starts.
Does the health endpoint still load?
- Stop any local Uvicorn process that still owns host port 8000.
- Check the container start command above for an accidental -p option.
Still getting a response? Help me identify what is serving my local health endpoint.
Your non-root container is live. Next, you'll publish its port to localhost.
Inject Runtime Credentials
Your Docker container already runs the assistant behind its own network boundary. The missing port publication keeps your host browser outside that boundary.
This step publishes the API on localhost. It passes your temporary AWS session into the container through runtime credential injection.
In this step, get ready to:
- Remove the deliberately isolated container.
- Load temporary AWS credentials into your current shell.
- Start a replacement container with localhost-only port publication.
Load temporary credentials
Boto3 follows the AWS credential chain when the agent calls AWS services. Environment variables give the container a temporary credential source that disappears with the container.
- Remove the isolated container and confirm its name disappears by running these commands:
docker rm --force cloud-ops-assistant
docker ps
What does this check prove?
- The first command stops the isolated container and removes it.
- The second command lists the containers that are still running.
You should no longer see a row named cloud-ops-assistant. The original network boundary is now gone.
Credential handling deserves care. The next command loads the temporary values into your current shell without writing them into Dockerfile.
- Load the temporary credentials and confirm the active AWS identity by running these commands:
eval "$(aws configure export-credentials --format env)"
aws sts get-caller-identity
How do the credentials stay temporary?
- The export command produces shell statements for AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN.
- The eval command applies those statements to your current shell without printing the credential values.
- The identity check returns account information without revealing any secret values.
You should see fields named UserId, Account, and Arn for your active identity.
Credentials not loading?
- Repeat the browser login from Step 1 if your temporary AWS session has expired.
- Return to the same terminal where you loaded the credentials before starting the container.
- Confirm your AWS identity still has permission to invoke Amazon Nova 2 Lite and describe EC2 instances.
Still stuck? Help me diagnose why my temporary AWS credentials are unavailable in this shell.
Start the reachable container
A port publication connects one host address to a port inside the container. A localhost-only binding keeps the API reachable from your workstation without publishing it on every network interface.
- Start the replacement container with runtime credentials and localhost-only port publication by running:
docker run --rm --name cloud-ops-assistant -d \
-p 127.0.0.1:8000:8000 \
-e AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY \
-e AWS_SESSION_TOKEN \
cloud-ops-assistant:1.0
What does this command do?
- The -p 127.0.0.1:8000:8000 option connects host port 8000 to container port 8000 through the loopback interface.
- Each -e option passes one existing shell variable into the container without placing its value in the command.
- The -d option runs the container in the background.
- The image continues to run the application as appuser.
You should see a container ID in the terminal. That ID confirms Docker started the replacement container.
- Confirm the replacement container is running by running:
docker ps
What should you see?
You should see cloud-ops-assistant running from cloud-ops-assistant:1.0. Its port details should show a localhost binding from host port 8000 to container port 8000.
Container missing from the list?
- Confirm you removed the previous container before reusing the name cloud-ops-assistant.
- Confirm you started the container from the shell that holds the three temporary AWS credential variables.
- Confirm the local image is still tagged cloud-ops-assistant:1.0.
Need another pair of eyes? Help me find out why my cloud-ops-assistant container stopped after startup.
Verify the containerized assistant
The FastAPI health endpoint checks the repaired network path. The agent request then checks whether the temporary credentials crossed the process boundary.
- Open http://127.0.0.1:8000/health in your browser.
- Confirm the response contains status with the value ok.
Your host can now reach the API through the published localhost port. The missing network path from the previous step is fixed.
The next request invokes Amazon Bedrock. Billing follows the input and output tokens processed.
This check creates no persistent AWS infrastructure. It only reads your identity and visible instance information.
- Open http://127.0.0.1:8000/docs in your browser.
- Select the POST /ask operation.
- Replace the example question in the request body with Which AWS account am I using, and what EC2 instances are visible in us-east-1?.
Before you submit the request, which live account details do you expect the two read-only tools to return?
- Submit the request through the interactive API form.
You should see the live caller ARN. You should also see an EC2 summary or an explicit empty result for us-east-1.
You crossed both boundaries successfully. The host reaches the containerized API and the agent reaches live AWS data with temporary credentials.
The successful request proves that credentials reached the running process. A source-file check confirms those credentials never entered the image.
- Use your Linux file browser to return to the cloud-ops-assistant source directory.
- Open Dockerfile with your text editor.
- Confirm none of the three AWS credential variable names appear in the file.
✔️ My Dockerfile has no credentials
Your image contains the application and its pinned dependencies. The temporary AWS credentials exist only in the current shell and running container.
ⓧ I'd like to double check the full code
- Compare your Dockerfile with this complete reference:
FROM public.ecr.aws/docker/library/python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Where is the credential boundary?
The file copies only requirements.txt and app.py into the image. The runtime command supplies the credential variables after the image has already been built.
Secret mission
Harden the Container Runtime
Your agent already runs as a non-root user with temporary AWS credentials. Add three runtime controls that restrict filesystem writes. Preserve the working API plus its temporary AWS authentication.
Clean Up Your Resources
Clean Up Your Resources
Your files and container run locally without ongoing infrastructure cost. Requests sent through the assistant can still incur usage-based Amazon Bedrock charges.
Cost warning
Each /ask request can invoke Amazon Nova 2 Lite. Bedrock charges for the input and output tokens processed.
This project created no persistent AWS infrastructure. There are no project-created cloud resources to delete.
Resources you used:
- Hardened Docker container named cloud-ops-assistant running on 127.0.0.1:8000.
- Local Docker image tagged cloud-ops-assistant:1.0.
- Local cloud-ops-assistant source directory containing the application files plus .venv.
- Temporary AWS credential values held in the current shell session.
Keep everything running
No action needed. Choose this if you are still testing the hardened cloud operations assistant.
- Keep the hardened container running at 127.0.0.1:8000 for more local tests.
- Use /ask only when you want to make another billed inference request.
- Recreate the container with fresh temporary credentials after the current AWS session expires.
Pause - I'll come back to this later
Remove the running container to free its memory and port. Your image and source directory stay available for later.
- Remove the running container by running these commands:
docker rm --force cloud-ops-assistant
docker ps
What does this pause do?
The first command stops the named container. Docker then removes it.
The final command lists running containers. You should see no row named cloud-ops-assistant.
- Leave cloud-ops-assistant:1.0 in Docker's local image list.
- Leave the cloud-ops-assistant source directory in place.
- Export a fresh temporary AWS session when you return.
- Relaunch the container with the hardened runtime options from the Secret Mission.
Delete - I don't want to use this again
This option removes every local project resource. The source directory cannot be recovered after deletion, which makes this the permanent choice.
- Remove both Docker resources by running these commands:
docker rm --force cloud-ops-assistant
docker image rm cloud-ops-assistant:1.0
What does this cleanup remove?
The first command removes the running container. The second command removes cloud-ops-assistant:1.0 from Docker's local image list.
Your source directory remains untouched at this point.
The next cleanup affects only the current shell session. Your AWS account and saved CLI configuration stay unchanged.
- Clear the temporary AWS credential values from the current shell by running:
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
What does this command clear?
The shell forgets those temporary values immediately. The command does not display the credentials.
- Switch back to the shell from earlier.
- Confirm its current directory by running:
pwd
What should you see?
The path should end with /cloud-ops-assistant. That ending confirms the next command targets the source directory from this project.
- Delete the source directory from its parent directory by running these commands:
cd ..
rm -rf cloud-ops-assistant
ls
What should remain?
The first command moves the shell to the directory above cloud-ops-assistant. The second command permanently removes the project directory.
The final command lists the remaining files. You should no longer see cloud-ops-assistant in the output.
Nice Work!
Nice Work!
You did it! Your containerized read-only AWS cloud operations assistant now answers live account questions through FastAPI.
You've learned how to:
- Connect a FastAPI API to Amazon Nova 2 Lite through a Strands Agents SDK agent. Create a fresh agent for each HTTP request. Limit every invocation with turn limits plus token budgets.
- Add read-only AWS tools for live account facts. Return the active caller identity from AWS Security Token Service. Summarize up to 25 visible EC2 instances in us-east-1.
- Package the service as a non-root Docker image. Diagnose the container network boundary by observing failed host access before port publication. Pass temporary AWS credentials only when the container starts. Keep the built image free of credentials by excluding them from Dockerfile.
- Complete the optional Secret Mission by hardening the container runtime. Run with --read-only. Mount writable temporary storage with --tmpfs /tmp. Prevent privilege escalation with --security-opt no-new-privileges=true.
Ready to quiz yourself?