This guide provides step-by-step instructions for deploying a production-ready AgentKit instance on a single AWS EC2 virtual machine using Docker Compose.
- Target Architecture
- EC2 Instance Sizing & Provisioning
- AWS Security Group Configuration
- Host Setup & Docker Installation
- Storage & Volume Persistence
- Environment & Secrets Management
- Systemd Service Daemon
- Reverse Proxy & Automatic SSL Termination
- Deployment & Verification Checklist
- Monitoring, Backups & Maintenance
Internet
│
▼
[AWS Security Group]
(Ports 80 & 443 Public; 22 Restricted)
│
▼
┌─────────────────────────────┐
│ EC2 Host (Ubuntu) │
│ │
│ [Reverse Proxy: Caddy] │
│ (Auto Let's Encrypt) │
│ │ │
│ ▼ │
│ [AgentKit API] (:8000) │
│ (FastAPI + Uvicorn) │
│ │ │ │
│ ▼ ▼ │
│ [PostgreSQL] [Redis] │
│ (:5432) (:6379) │
│ (Internal) (Internal) │
└─────────────────────────────┘
The single-node EC2 architecture runs the complete containerized stack:
- Reverse Proxy: Caddy or Nginx handling automatic TLS / HTTPS termination on ports 80/443.
- AgentKit Application: FastAPI backend exposed on internal port
8000. - Database: PostgreSQL 16 Alpine container with attached persistent EBS volume.
- Cache & Rate Limiting: Redis 7 Alpine container for session memory and rate limits.
- Internal Network: Docker bridge network (
agentkit_net) isolating PostgreSQL and Redis from public exposure.
- Development / Staging:
t3.small(2 vCPU, 2 GiB RAM) - Production Minimum:
t3.medium(2 vCPU, 4 GiB RAM) - High Throughput:
c6i.largeorc7g.large(2 vCPU, 4 GiB RAM, Compute-optimized) - Root Volume: Minimum 30 GiB gp3 EBS volume (3000 IOPS, 125 MB/s throughput baseline).
# Launch Ubuntu 24.04 LTS instance
aws ec2 run-instances \
--image-id ami-04b70fa74e45c3917 \
--instance-type t3.medium \
--key-name your-ec2-keypair \
--security-group-ids sg-0123456789abcdef0 \
--subnet-id subnet-0123456789abcdef0 \
--block-device-mappings '[{"DeviceName":"/dev/sda1","Ebs":{"VolumeSize":30,"VolumeType":"gp3","DeleteOnTermination":false}}]' \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=agentkit-production}]'Caution
Never expose ports 5432 (PostgreSQL) or 6379 (Redis) to 0.0.0.0/0. They must remain accessible exclusively within the internal Docker network.
| Protocol | Port | Source | Description |
|---|---|---|---|
| SSH (TCP) | 22 |
YOUR_OFFICE_IP/32 |
Administrative SSH access (strictly restricted) |
| HTTP (TCP) | 80 |
0.0.0.0/0, ::/0 |
Let's Encrypt HTTP-01 challenge & HTTP -> HTTPS redirect |
| HTTPS (TCP) | 443 |
0.0.0.0/0, ::/0 |
Encrypted public REST API traffic |
# 1. Create dedicated Security Group
SG_ID=$(aws ec2 create-security-group \
--group-name "agentkit-sg" \
--description "Security Group for AgentKit EC2 Host" \
--query "GroupId" --output text)
# 2. Allow SSH only from your trusted IP
aws ec2 authorize-security-group-ingress \
--group-id "$SG_ID" \
--protocol tcp --port 22 --cidr "203.0.113.45/32"
# 3. Allow public HTTP/HTTPS for Web & ACME TLS
aws ec2 authorize-security-group-ingress \
--group-id "$SG_ID" \
--protocol tcp --port 80 --cidr "0.0.0.0/0"
aws ec2 authorize-security-group-ingress \
--group-id "$SG_ID" \
--protocol tcp --port 443 --cidr "0.0.0.0/0"Connect to your EC2 instance via SSH:
ssh -i /path/to/key.pem ubuntu@<EC2_PUBLIC_IP>Update system packages and install official Docker CE and Docker Compose:
# Update repository lists
sudo apt-get update && sudo apt-get upgrade -y
sudo apt-get install -y ca-certificates curl gnupg lsb-release
# Install Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add Docker repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine and Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable Docker daemon on boot and add ubuntu user to docker group
sudo systemctl enable --now docker
sudo usermod -aG docker ubuntu
newgrp dockerCreate the application directory structure:
sudo mkdir -p /opt/agentkit /var/log/agentkit
sudo chown -R ubuntu:ubuntu /opt/agentkit /var/log/agentkit
cd /opt/agentkitClone the repository:
git clone https://github.com/getdownandcode/AgentKit.git .Verify the local volume directories:
postgres_data: Docker managed volume or mount at/opt/agentkit/volumes/postgresredis_data: Docker managed volume or mount at/opt/agentkit/volumes/redis
Store secrets securely using AWS Systems Manager (SSM) Parameter Store or AWS Secrets Manager.
Fetch production secrets dynamically using the AWS CLI:
# Create parameters in AWS SSM
aws ssm put-parameter --name "/agentkit/production/DATABASE_PASSWORD" \
--value "$(openssl rand -hex 24)" --type "SecureString"
aws ssm put-parameter --name "/agentkit/production/READONLY_PASSWORD" \
--value "$(openssl rand -hex 24)" --type "SecureString"
aws ssm put-parameter --name "/agentkit/production/REDIS_PASSWORD" \
--value "$(openssl rand -hex 24)" --type "SecureString"
aws ssm put-parameter --name "/agentkit/production/API_KEYS" \
--value "prod_key_$(openssl rand -hex 16)" --type "SecureString"
aws ssm put-parameter --name "/agentkit/production/GEMINI_API_KEY" \
--value "AIzaSy..." --type "SecureString"
# Export parameters directly into restricted .env file on the host
cat << 'EOF' > /opt/agentkit/fetch_secrets.sh
#!/usr/bin/env bash
set -euo pipefail
DB_PASS=$(aws ssm get-parameter --name "/agentkit/production/DATABASE_PASSWORD" --with-decryption --query "Parameter.Value" --output text)
RO_PASS=$(aws ssm get-parameter --name "/agentkit/production/READONLY_PASSWORD" --with-decryption --query "Parameter.Value" --output text)
REDIS_PASS=$(aws ssm get-parameter --name "/agentkit/production/REDIS_PASSWORD" --with-decryption --query "Parameter.Value" --output text)
API_KEYS=$(aws ssm get-parameter --name "/agentkit/production/API_KEYS" --with-decryption --query "Parameter.Value" --output text)
GEMINI_KEY=$(aws ssm get-parameter --name "/agentkit/production/GEMINI_API_KEY" --with-decryption --query "Parameter.Value" --output text)
# Credentials are written as components, not inside connection URLs: the generated
# passwords contain '/', '+' and '=', and AgentKit escapes them while it builds the URL.
cat << ENVFILE > /opt/agentkit/.env
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=agentkit
POSTGRES_PASSWORD=${DB_PASS}
POSTGRES_READONLY_USER=agentkit_readonly
POSTGRES_READONLY_PASSWORD=${RO_PASS}
POSTGRES_DB=agentkit
REDIS_PASSWORD=${REDIS_PASS}
REDIS_URL=redis://redis:6379/0
LLM_PROVIDER=gemini
LLM_MODEL=gemini-3.1-flash-lite
GEMINI_API_KEY=${GEMINI_KEY}
API_KEYS=${API_KEYS}
FILE_TOOL_BASE_DIR=/tmp/agentkit_sandbox
RUN_TIMEOUT_S=60
LLM_MAX_RETRIES=3
ENVFILE
chmod 600 /opt/agentkit/.env
echo ".env successfully updated from AWS SSM."
EOF
chmod +x /opt/agentkit/fetch_secrets.sh
./opt/agentkit/fetch_secrets.shIf configuring manually, create /opt/agentkit/.env from .env.example:
cp .env.example .env
chmod 600 .env # Restrict permissions: read/write only for ownerThe compose stack treats PostgreSQL and Redis as internal services, because everything it stores — run goals, final answers, full conversation history, rate-limit counters — is readable by anyone who can reach those ports.
- Loopback-only ports by default.
postgresandredispublish on127.0.0.1(${POSTGRES_BIND:-127.0.0.1}/${REDIS_BIND:-127.0.0.1}). Containers reach each other overagentkit-net; the host mapping exists only for local inspection. SettingPOSTGRES_BIND=0.0.0.0orREDIS_BIND=0.0.0.0is a deliberate opt-in — do not do it on a shared host or CI runner without a firewall rule in front. - No default passwords. Compose requires
POSTGRES_PASSWORD,POSTGRES_READONLY_PASSWORDandREDIS_PASSWORD; interpolation fails when one is missing instead of silently falling back topostgres/postgresor an unauthenticated Redis. Generate each one withopenssl rand -base64 24. - Credentials are never interpolated into a URL. Compose passes
POSTGRES_HOST,POSTGRES_PORT,POSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORDand thePOSTGRES_READONLY_*pair;SettingsassemblesDATABASE_URLandDATABASE_READONLY_URLfrom them and percent-encodes every component. This matters becauseopenssl rand -base64 24produces/,+and=: a raw/ends the URL authority, so the remainder of the password becomes the path and the connection resolves to a database that does not exist. An explicitly configuredDATABASE_URL/DATABASE_READONLY_URLstill wins, for a managed database behind a secret substitution. Inside the stack the service names are authoritative, so a host-side.envpointing atlocalhostcannot leak into a container. - Redis authenticates. The service starts with
redis-server --requirepasssourced fromREDIS_PASSWORD.REDIS_PASSWORDis authoritative: it is embedded (percent-encoded) into aREDIS_URLthat carries no credentials of its own, so the secret need not be repeated inside the URL, and an explicit password in the URL still wins. The healthcheck authenticates too, so a password mismatch surfaces as an unhealthy container rather than 401s at runtime. The real-infrastructure suite resolves credentials the same way, which is whyAGENTKIT_REAL_INFRA=1 pytest -m real_infra tests/realworks against this stack without aREDIS_TEST_URL— passREDIS_PASSWORD(or let it come from.env) with any override URL. - The guard fails closed. The
migrationsservice runspython scripts/check_deploy_credentials.pybefore anything else. It audits the credentials actually used, whether they come from components or from an explicit URL: it rejects the image defaults, the documented placeholders, short passwords, a URL with no password, a malformed URL, a missing read-only URL, a read-only role equal to the application role, and aREDIS_URLwithout a password. A URL-only deployment is not required to repeat the*_PASSWORDcomponents it does not use. - Read-only SQL role.
python scripts/init_readonly_role.pyprovisionsPOSTGRES_READONLY_USERwithCONNECT/USAGE/SELECTonly anddefault_transaction_read_only = on. Thesql_readonlytool connects with it and refuses to run whenDATABASE_READONLY_URLis absent, so the agent never touches the application role. A role that already exists is restricted, not merely given a new password: the script revokesSUPERUSER,CREATEDB,CREATEROLE,REPLICATIONandBYPASSRLS, drops every inherited role membership (pg_read_server_fileswould otherwise allowpg_read_file) and revokes all non-SELECTobject privileges before grantingSELECTback. It then verifies the result and exits non-zero if anything remains, and it refuses a role that owns the database, because an owner can grant itself anything. At request time the tool independently refuses a superuser, a role withBYPASSRLS, or a role that is (directly or indirectly) a member ofpg_read_server_files,pg_write_server_files,pg_execute_server_program,pg_signal_backend,pg_monitororpg_maintain.
Verify the effective configuration before starting the stack:
docker compose config | grep -E "POSTGRES_BIND|REDIS_BIND|requirepass|POSTGRES_READONLY_USER|REDIS_URL"No credential appears inside a URL in that output: the datastore URLs are built by the application from the component variables.
Create a systemd service file at /etc/systemd/system/agentkit.service to manage the lifecycle of the Docker Compose stack across host restarts.
[Unit]
Description=AgentKit Docker Compose Application Service
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/agentkit
User=ubuntu
Group=docker
# Pre-execution: pull latest images or fetch credentials
ExecStartPre=-/usr/bin/docker compose pull --quiet
# Execution: start stack in detached mode
ExecStart=/usr/bin/docker compose up -d --remove-orphans
# Teardown: stop stack gracefully
ExecStop=/usr/bin/docker compose down --timeout 30
# Restart policy for the service unit itself
Restart=on-failure
RestartSec=10s
TimeoutStartSec=300
[Install]
WantedBy=multi-user.targetReload systemd and enable the service on boot:
sudo systemctl daemon-reload
sudo systemctl enable agentkit.service
sudo systemctl start agentkit.service
sudo systemctl status agentkit.serviceCaddy automatically obtains and renews Let's Encrypt TLS certificates with no cron jobs required.
- Install Caddy on Ubuntu:
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLF 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLF 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy- Configure
/etc/caddy/Caddyfile:
api.yourdomain.com {
# Automatic TLS enabled by default
encode gzip zstd
# Security Headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Proxy to AgentKit FastAPI backend
reverse_proxy localhost:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Healthcheck passive circuit breaker
fail_duration 10s
max_fails 3
}
# Access logging
log {
output file /var/log/caddy/agentkit_access.log {
roll_size 50mb
roll_keep 5
}
}
}- Restart Caddy:
sudo systemctl restart caddyIf your organization standardizes on Nginx:
- Install Nginx and Certbot:
sudo apt-get install -y nginx certbot python3-certbot-nginx- Create
/etc/nginx/sites-available/agentkit:
server {
server_name api.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (for streaming endpoints)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts for long agent reasoning runs
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
listen 80;
}- Enable site and generate SSL certificate:
sudo ln -s /etc/nginx/sites-available/agentkit /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl restart nginx
# Obtain certificate via Let's Encrypt Certbot
sudo certbot --nginx -d api.yourdomain.com --non-interactive --agree-tos -m admin@yourdomain.comOnce the stack is running, execute the verification checklist:
-
Verify Docker Containers:
docker compose ps # All 3 services (postgres, redis, api) should show 'healthy' status -
Verify Database Migrations:
docker compose logs api | grep -i "running migrations"
-
Verify API Healthcheck (Loopback):
curl -s http://127.0.0.1:8000/health | jq . # Expected output: # { # "status": "healthy", # "database": "connected", # "redis": "connected", # "version": "0.1.0" # }
-
Verify HTTPS & SSL Termination:
curl -I https://api.yourdomain.com/health # Expected: HTTP/2 200 OK with valid TLS certificate -
Verify Authenticated Agent Run:
curl -X POST https://api.yourdomain.com/runs \ -H "X-API-Key: YOUR_CONFIGURED_API_KEY" \ -H "Content-Type: application/json" \ -d '{"goal": "Calculate 42 * 100 using calculator", "session_id": "test-session"}' | jq .
Configure daily automated backups to AWS S3:
# Add backup script /opt/agentkit/backup_db.sh
cat << 'EOF' > /opt/agentkit/backup_db.sh
#!/usr/bin/env bash
set -euo pipefail
TIMESTAMP=$(date +\%Y\%m\%d_\%H\%M\%S)
BACKUP_FILE="/tmp/agentkit_backup_${TIMESTAMP}.sql.gz"
docker compose exec -T postgres pg_dump -U agentkit agentkit | gzip > "$BACKUP_FILE"
aws s3 cp "$BACKUP_FILE" "s3://your-agentkit-backups-bucket/db/${TIMESTAMP}.sql.gz"
rm -f "$BACKUP_FILE"
echo "Backup ${TIMESTAMP} completed successfully."
EOF
chmod +x /opt/agentkit/backup_db.sh
# Add to crontab: daily at 02:00 UTC
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/agentkit/backup_db.sh >> /var/log/agentkit/backup.log 2>&1") | crontab -To prevent containers from exhausting disk space, ensure Docker daemon log rotation is configured in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}Apply with sudo systemctl restart docker.