> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goguava.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploying Guava Agents on AWS ECS (Fargate)

> Run a Guava agent as an ECS service on Fargate

Guava agents can run on [Amazon ECS](https://aws.amazon.com/ecs/) using Fargate. The [Expert](https://docs.goguava.ai/getting-started/architecture-overview.md) opens an outbound WebSocket connection to Guava, so it runs as a plain ECS service. You don't need a load balancer, inbound ports, or a public endpoint.

This guide shows the AWS infrastructure three ways: the AWS CDK, Terraform, and AWS CLI commands. Pick one. They all create the same resources.

> [card] At a Glance
>
> 1. Containerize your agent with a Dockerfile.
> 2. Store your Guava API key in AWS Secrets Manager.
> 3. (Skip if adding to an existing ECS application) Create an ECR repository, an execution role, and an ECS cluster.
> 4. Create a Fargate task definition and an ECS service with no load balancer.
> 5. Push your image and let ECS start the tasks.

## Prerequisites

* An AWS account, and the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) configured with credentials
* Docker and [uv](https://docs.astral.sh/uv/getting-started/installation/)
* [Terraform](https://developer.hashicorp.com/terraform/install) or [Node.js](https://nodejs.org/) (for the AWS CDK), if you're using either
* A Guava phone number for your agent

## Define your agent

Define your agent in `agent.py`. The phone number comes from the environment, so the same image can be deployed for any number. `agent.listen_phone(...)` connects your agent to the phone number and does not exit.

```python agent.py theme={null}
import os
import guava

from guava import logging_utils

agent = guava.Agent(
    purpose="You are a helpful voice agent.",
    name="Hannah",
)

@agent.on_call_start()
def on_call_start(call: guava.Call):
    # Set your first task here using call.set_task(...)
    pass

if __name__ == "__main__":
    logging_utils.configure_logging()
    agent.listen_phone(os.environ["GUAVA_AGENT_NUMBER"])
```

If your project doesn't have a `pyproject.toml` yet, create one with [uv](https://docs.astral.sh/uv/).

```bash terminal theme={null}
uv init --bare --python 3.13
```

Add `guava-sdk` as a dependency. This updates `pyproject.toml` and `uv.lock`, which the Dockerfile installs from.

```bash terminal theme={null}
uv add guava-sdk
```

## Containerize your agent

Add a `Dockerfile` and a `.dockerignore` to your project root.

```dockerfile Dockerfile theme={null}
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Use the image's Python; fail the build if pyproject.toml needs a different one.
ENV PYTHONUNBUFFERED=1 \
    UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=never

WORKDIR /app

# Install dependencies in their own layer, so code changes don't reinstall them.
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY . .
RUN uv sync --locked --no-dev

ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "agent.py"]
```

```bash .dockerignore theme={null}
.git
.venv
__pycache__
.env
infra/
```

`requires-python` in `pyproject.toml` must allow the Python version in `FROM python:…`. If it doesn't, the build fails.

> [!NOTE]
>
> Fargate tasks in this guide run on x86\_64, so images are built with `--platform linux/amd64`. Without it, images built on Apple Silicon Macs won't start on Fargate.

## Store your Guava API key

Open the [API Keys page in the Guava dashboard](https://app.goguava.ai/dashboard/api-keys) and click **Create API Key**. The key has the form `gva-...`. Store it in AWS Secrets Manager. This command and all the following ones use your AWS CLI's configured region, so make sure it's the region you want to deploy to.

```bash terminal theme={null}
aws secretsmanager create-secret \
  --name guava/api-key \
  --secret-string "gva-..."
```

Copy the `ARN` from the output. ECS reads the secret when each task starts and passes it to your agent as the `GUAVA_API_KEY` environment variable, so the key never appears in your task definition.

## Provision the infrastructure

Each option below creates:

* An **ECR repository** for your agent's image
* A **CloudWatch log group** for your agent's logs
* An **execution role** that pulls the image, writes logs, and reads the API key
* An **ECS cluster** and a **Fargate task definition** (1 vCPU, 2 GB)
* An **ECS service** that runs two tasks in your default VPC's public subnets, with a security group that only allows outbound traffic

> [tab] AWS CDK
>
> Create a CDK app in an `infra/` directory in your project.
>
> ```bash terminal theme={null}
> mkdir infra && cd infra
> npx cdk init app --language typescript
> ```
>
> Replace the contents of `infra/bin/infra.ts` with the following stack. The CDK builds your Dockerfile and pushes the image for you, so you don't need to create an ECR repository yourself.
>
> ```typescript infra/bin/infra.ts theme={null}
> #!/usr/bin/env node
> import * as path from "path";
> import * as cdk from "aws-cdk-lib";
> import * as ec2 from "aws-cdk-lib/aws-ec2";
> import * as ecs from "aws-cdk-lib/aws-ecs";
> import * as logs from "aws-cdk-lib/aws-logs";
> import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
> import { Platform } from "aws-cdk-lib/aws-ecr-assets";
> import { Construct } from "constructs";
>
> const AGENT_NUMBER = "+1..."; // Replace with your agent's phone number.
>
> class GuavaAgentStack extends cdk.Stack {
>   constructor(scope: Construct, id: string, props?: cdk.StackProps) {
>     super(scope, id, props);
>
>     const vpc = ec2.Vpc.fromLookup(this, "Vpc", { isDefault: true });
>     const cluster = new ecs.Cluster(this, "Cluster", { vpc, clusterName: "guava" });
>     const apiKey = secretsmanager.Secret.fromSecretNameV2(this, "GuavaApiKey", "guava/api-key");
>
>     const logGroup = new logs.LogGroup(this, "Logs", {
>       logGroupName: "/ecs/guava-agent",
>       retention: logs.RetentionDays.ONE_MONTH,
>       removalPolicy: cdk.RemovalPolicy.DESTROY,
>     });
>
>     const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDefinition", {
>       cpu: 1024,
>       memoryLimitMiB: 2048,
>     });
>
>     taskDefinition.addContainer("agent", {
>       // Builds the Dockerfile in the project root and pushes it on `cdk deploy`.
>       image: ecs.ContainerImage.fromAsset(path.join(__dirname, "..", ".."), {
>         platform: Platform.LINUX_AMD64,
>       }),
>       stopTimeout: cdk.Duration.seconds(120),
>       environment: {
>         GUAVA_AGENT_NUMBER: AGENT_NUMBER,
>         GUAVA_HEALTH_SERVER: "true",
>       },
>       secrets: {
>         GUAVA_API_KEY: ecs.Secret.fromSecretsManager(apiKey),
>       },
>       healthCheck: {
>         command: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4828/live')"],
>         interval: cdk.Duration.seconds(30),
>         timeout: cdk.Duration.seconds(5),
>         retries: 3,
>         startPeriod: cdk.Duration.seconds(30),
>       },
>       logging: ecs.LogDrivers.awsLogs({ logGroup, streamPrefix: "agent" }),
>     });
>
>     // The CDK creates a security group that allows all outbound traffic and no inbound traffic.
>     new ecs.FargateService(this, "Service", {
>       cluster,
>       serviceName: "guava-agent",
>       taskDefinition,
>       desiredCount: 2,
>       assignPublicIp: true,
>       // Keep both tasks running during deployments, and roll back if new tasks keep failing to start.
>       minHealthyPercent: 100,
>       circuitBreaker: { rollback: true },
>     });
>   }
> }
>
> const app = new cdk.App();
> new GuavaAgentStack(app, "GuavaAgentStack", {
>   env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
> });
> ```
>
> Bootstrap your account. You only need to do this once per account and region.
>
> ```bash terminal theme={null}
> npx cdk bootstrap
> ```
>
> Deploy the stack.
>
> ```bash terminal theme={null}
> npx cdk deploy
> ```

> [tab] Terraform
>
> Create an `infra/` directory in your project with the following configuration.
>
> ```hcl infra/main.tf theme={null}
> terraform {
>   required_providers {
>     aws = {
>       source  = "hashicorp/aws"
>       version = "~> 6.0"
>     }
>   }
> }
>
> variable "agent_number" {
>   type = string
> }
>
> variable "guava_api_key_secret_arn" {
>   type = string
> }
>
> # The region comes from your AWS CLI configuration.
> data "aws_region" "current" {}
>
> data "aws_vpc" "default" {
>   default = true
> }
>
> data "aws_subnets" "default" {
>   filter {
>     name   = "vpc-id"
>     values = [data.aws_vpc.default.id]
>   }
> }
>
> resource "aws_ecr_repository" "agent" {
>   name         = "guava-agent"
>   force_delete = true
> }
>
> resource "aws_cloudwatch_log_group" "agent" {
>   name              = "/ecs/guava-agent"
>   retention_in_days = 30
> }
>
> data "aws_iam_policy_document" "ecs_tasks_assume" {
>   statement {
>     actions = ["sts:AssumeRole"]
>     principals {
>       type        = "Service"
>       identifiers = ["ecs-tasks.amazonaws.com"]
>     }
>   }
> }
>
> # Used by ECS to pull the image, write logs, and read the API key.
> resource "aws_iam_role" "execution" {
>   name               = "guava-agent-execution"
>   assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
> }
>
> resource "aws_iam_role_policy_attachment" "execution" {
>   role       = aws_iam_role.execution.name
>   policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
> }
>
> resource "aws_iam_role_policy" "execution_read_api_key" {
>   role = aws_iam_role.execution.id
>   policy = jsonencode({
>     Version = "2012-10-17"
>     Statement = [{
>       Effect   = "Allow"
>       Action   = "secretsmanager:GetSecretValue"
>       Resource = var.guava_api_key_secret_arn
>     }]
>   })
> }
>
> resource "aws_ecs_cluster" "guava" {
>   name = "guava"
> }
>
> # Outbound only. The agent connects out to Guava, so no ingress is needed.
> resource "aws_security_group" "agent" {
>   name   = "guava-agent"
>   vpc_id = data.aws_vpc.default.id
>
>   egress {
>     from_port   = 0
>     to_port     = 0
>     protocol    = "-1"
>     cidr_blocks = ["0.0.0.0/0"]
>   }
> }
>
> resource "aws_ecs_task_definition" "agent" {
>   family                   = "guava-agent"
>   requires_compatibilities = ["FARGATE"]
>   network_mode             = "awsvpc"
>   cpu                      = 1024
>   memory                   = 2048
>   execution_role_arn       = aws_iam_role.execution.arn
>
>   container_definitions = jsonencode([{
>     name        = "agent"
>     image       = "${aws_ecr_repository.agent.repository_url}:latest"
>     stopTimeout = 120
>
>     environment = [
>       { name = "GUAVA_AGENT_NUMBER", value = var.agent_number },
>       { name = "GUAVA_HEALTH_SERVER", value = "true" },
>     ]
>     secrets = [
>       { name = "GUAVA_API_KEY", valueFrom = var.guava_api_key_secret_arn },
>     ]
>
>     healthCheck = {
>       command     = ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4828/live')"]
>       interval    = 30
>       timeout     = 5
>       retries     = 3
>       startPeriod = 30
>     }
>
>     logConfiguration = {
>       logDriver = "awslogs"
>       options = {
>         "awslogs-group"         = aws_cloudwatch_log_group.agent.name
>         "awslogs-region"        = data.aws_region.current.region
>         "awslogs-stream-prefix" = "agent"
>       }
>     }
>   }])
> }
>
> resource "aws_ecs_service" "agent" {
>   name            = "guava-agent"
>   cluster         = aws_ecs_cluster.guava.id
>   task_definition = aws_ecs_task_definition.agent.arn
>   desired_count   = 2
>   launch_type     = "FARGATE"
>
>   # Roll back a deployment whose tasks keep failing to start.
>   deployment_circuit_breaker {
>     enable   = true
>     rollback = true
>   }
>
>   network_configuration {
>     subnets          = data.aws_subnets.default.ids
>     security_groups  = [aws_security_group.agent.id]
>     assign_public_ip = true
>   }
>
>   # Tasks need these permissions to pull the image and read the API key.
>   depends_on = [
>     aws_iam_role_policy_attachment.execution,
>     aws_iam_role_policy.execution_read_api_key,
>   ]
> }
>
> output "repository_url" {
>   value = aws_ecr_repository.agent.repository_url
> }
> ```
>
> ```hcl infra/terraform.tfvars theme={null}
> agent_number             = "+1..." # Replace with your agent's phone number.
> guava_api_key_secret_arn = "arn:aws:secretsmanager:..." # From the previous step.
> ```
>
> The service needs an image to run, so create the ECR repository first, push your image to it, and then apply the rest of the configuration. Run these from your project root.
>
> ```bash terminal theme={null}
> terraform -chdir=infra init
> terraform -chdir=infra apply -target=aws_ecr_repository.agent
>
> REPO_URL=$(terraform -chdir=infra output -raw repository_url)
> aws ecr get-login-password | docker login --username AWS --password-stdin "${REPO_URL%%/*}"
> docker build --platform linux/amd64 -t "${REPO_URL}:latest" .
> docker push "${REPO_URL}:latest"
>
> terraform -chdir=infra apply
> ```

> [tab] AWS CLI
>
> Run these commands from your project root, in the same shell session. First, set some variables used by the later commands, replacing `+1...` with your agent's phone number. Setting `AWS_PAGER` to empty makes the AWS CLI print output directly instead of opening a pager.
>
> ```bash terminal theme={null}
> export AWS_REGION=${AWS_REGION:-$(aws configure get region)}
> export AWS_PAGER=""
> AGENT_NUMBER="+1..."
> ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
> SECRET_ARN=$(aws secretsmanager describe-secret --secret-id guava/api-key --query ARN --output text)
> ```
>
> Create the ECR repository and push your image.
>
> ```bash terminal theme={null}
> aws ecr create-repository --repository-name guava-agent
>
> REPO_URL="$ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/guava-agent"
> aws ecr get-login-password | docker login --username AWS --password-stdin "${REPO_URL%%/*}"
> docker build --platform linux/amd64 -t "${REPO_URL}:latest" .
> docker push "${REPO_URL}:latest"
> ```
>
> Create the log group.
>
> ```bash terminal theme={null}
> aws logs create-log-group --log-group-name /ecs/guava-agent
> aws logs put-retention-policy --log-group-name /ecs/guava-agent --retention-in-days 30
> ```
>
> Create the execution role, which ECS uses to pull the image, write logs, and read the API key.
>
> ```bash terminal theme={null}
> aws iam create-role --role-name guava-agent-execution \
>   --assume-role-policy-document '{
>     "Version": "2012-10-17",
>     "Statement": [{
>       "Effect": "Allow",
>       "Principal": { "Service": "ecs-tasks.amazonaws.com" },
>       "Action": "sts:AssumeRole"
>     }]
>   }'
> aws iam attach-role-policy --role-name guava-agent-execution \
>   --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
> aws iam put-role-policy --role-name guava-agent-execution \
>   --policy-name read-guava-api-key \
>   --policy-document "{
>     \"Version\": \"2012-10-17\",
>     \"Statement\": [{
>       \"Effect\": \"Allow\",
>       \"Action\": \"secretsmanager:GetSecretValue\",
>       \"Resource\": \"$SECRET_ARN\"
>     }]
>   }"
> ```
>
> Register the task definition.
>
> ```bash terminal theme={null}
> TASK_DEFINITION=$(cat <<EOF
> {
>   "family": "guava-agent",
>   "requiresCompatibilities": ["FARGATE"],
>   "networkMode": "awsvpc",
>   "cpu": "1024",
>   "memory": "2048",
>   "executionRoleArn": "arn:aws:iam::$ACCOUNT_ID:role/guava-agent-execution",
>   "containerDefinitions": [{
>     "name": "agent",
>     "image": "${REPO_URL}:latest",
>     "stopTimeout": 120,
>     "environment": [
>       { "name": "GUAVA_AGENT_NUMBER", "value": "$AGENT_NUMBER" },
>       { "name": "GUAVA_HEALTH_SERVER", "value": "true" }
>     ],
>     "secrets": [
>       { "name": "GUAVA_API_KEY", "valueFrom": "$SECRET_ARN" }
>     ],
>     "healthCheck": {
>       "command": ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4828/live')"],
>       "interval": 30,
>       "timeout": 5,
>       "retries": 3,
>       "startPeriod": 30
>     },
>     "logConfiguration": {
>       "logDriver": "awslogs",
>       "options": {
>         "awslogs-group": "/ecs/guava-agent",
>         "awslogs-region": "$AWS_REGION",
>         "awslogs-stream-prefix": "agent"
>       }
>     }
>   }]
> }
> EOF
> )
>
> aws ecs register-task-definition --cli-input-json "$TASK_DEFINITION"
> ```
>
> Create the cluster, a security group, and the service. New security groups allow all outbound traffic and no inbound traffic.
>
> ```bash terminal theme={null}
> aws ecs create-cluster --cluster-name guava
>
> VPC_ID=$(aws ec2 describe-vpcs --filters Name=is-default,Values=true \
>   --query 'Vpcs[0].VpcId' --output text)
> SUBNETS=$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$VPC_ID \
>   --query 'Subnets[].SubnetId' --output text | tr '\t' ',')
>
> SG_ID=$(aws ec2 create-security-group --group-name guava-agent \
>   --description "Guava agent (outbound only)" --vpc-id "$VPC_ID" \
>   --query GroupId --output text)
>
> aws ecs create-service \
>   --cluster guava \
>   --service-name guava-agent \
>   --task-definition guava-agent \
>   --desired-count 2 \
>   --launch-type FARGATE \
>   --network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG_ID],assignPublicIp=ENABLED}" \
>   --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}"
> ```

> [!NOTE]
>
> For production, consider running tasks in private subnets with a NAT gateway and turning off public IP assignment. The agent only needs outbound access to Guava and to any APIs it calls.

## Call your agent

Tail your agent's logs. Once each task logs that it started listening on your phone number, you can call your agent.

```bash terminal theme={null}
aws logs tail /ecs/guava-agent --follow
```

Both tasks listen on the same phone number, and each incoming call is handled by one of them. If a task stops, the other keeps taking calls while ECS starts a replacement.

## Deploying updates

> [tab] AWS CDK
>
> Run `cdk deploy` again. The CDK rebuilds the image, pushes it, and registers a new task definition revision.
>
> ```bash terminal theme={null}
> npx cdk deploy
> ```

> [tab] Terraform
>
> The task definition uses the `latest` tag, so push a new image and start a new deployment.
>
> ```bash terminal theme={null}
> REPO_URL=$(terraform -chdir=infra output -raw repository_url)
> aws ecr get-login-password | docker login --username AWS --password-stdin "${REPO_URL%%/*}"
> docker build --platform linux/amd64 -t "${REPO_URL}:latest" .
> docker push "${REPO_URL}:latest"
> aws ecs update-service --cluster guava --service guava-agent --force-new-deployment
> ```

> [tab] AWS CLI
>
> The task definition uses the `latest` tag, so push a new image and start a new deployment.
>
> ```bash terminal theme={null}
> REPO_URL=$(aws ecr describe-repositories --repository-names guava-agent \
>   --query 'repositories[0].repositoryUri' --output text)
> aws ecr get-login-password | docker login --username AWS --password-stdin "${REPO_URL%%/*}"
> docker build --platform linux/amd64 -t "${REPO_URL}:latest" .
> docker push "${REPO_URL}:latest"
> aws ecs update-service --cluster guava --service guava-agent --force-new-deployment
> ```

ECS performs a rolling deployment. It starts new tasks and stops old ones only while at least two healthy tasks stay running.

## Graceful shutdown and health checks

When ECS stops a task, it sends `SIGTERM`. The Guava SDK responds by draining: it stops accepting new calls and lets in-flight calls finish, while the other tasks pick up new calls. The task definition sets `stopTimeout` to 120 seconds, the Fargate maximum, which is how long ECS waits before force-stopping the container.

Setting `GUAVA_HEALTH_SERVER=true` starts a health check server inside the container on port `4828`:

| Endpoint | Returns `200` when |
| - | - |
| `/live` | The agent process is running, including while it drains. |
| `/ready` | The agent is connected and accepting calls. |

The container health check uses `/live`. Don't use `/ready` for it: `/ready` returns `503` while a task drains, so ECS would mark tasks unhealthy while they are still finishing calls.

## Clean up

> [tab] AWS CDK
>
> From `infra/`, destroy the stack. The CDK bootstrap stack (`CDKToolkit`) stays, since every CDK app in the account and region shares it.
>
> ```bash terminal theme={null}
> npx cdk destroy
> ```

> [tab] Terraform
>
> Destroy everything Terraform created. Deleting the service takes a few minutes while its tasks drain.
>
> ```bash terminal theme={null}
> terraform -chdir=infra destroy
> ```

> [tab] AWS CLI
>
> Delete the resources in reverse order. `--force` scales the service to zero and deletes it in one step. The security group can only be deleted once the tasks' network interfaces are gone, so the commands wait for the service to finish deleting first.
>
> ```bash terminal theme={null}
> aws ecs delete-service --cluster guava --service guava-agent --force --query service.status --output text
> aws ecs wait services-inactive --cluster guava --services guava-agent
> aws ecs delete-cluster --cluster guava --query cluster.status --output text
>
> VPC_ID=$(aws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text)
> SG_ID=$(aws ec2 describe-security-groups --filters Name=group-name,Values=guava-agent Name=vpc-id,Values=$VPC_ID \
>   --query 'SecurityGroups[0].GroupId' --output text)
> aws ec2 delete-security-group --group-id "$SG_ID"
>
> aws ecr delete-repository --repository-name guava-agent --force --query repository.repositoryName --output text
> aws logs delete-log-group --log-group-name /ecs/guava-agent
>
> aws iam delete-role-policy --role-name guava-agent-execution --policy-name read-guava-api-key
> aws iam detach-role-policy --role-name guava-agent-execution \
>   --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
> aws iam delete-role --role-name guava-agent-execution
> ```

The API key secret isn't part of any of these. To delete it, use `--force-delete-without-recovery`. Otherwise Secrets Manager keeps the `guava/api-key` name reserved for 7 to 30 days.

```bash terminal theme={null}
aws secretsmanager delete-secret --secret-id guava/api-key --force-delete-without-recovery
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.