AWS Standard ECS
This page walks through deploying the formae agent on a standard ECS Service on Fargate — the recommended production deployment. The agent runs in private subnets in a VPC of your choosing (typically separate from the infrastructure it manages), uses direct PostgreSQL as its datastore, and is reached over your own connectivity (VPN, bastion, Tailscale, PrivateLink).
For a faster prototyping path, see ECS Express Mode. For background on the trade-offs, see the AWS installation overview.
Prerequisites
- formae CLI installed on your local machine — see local installation
- AWS CLI authenticated against the account you'll deploy into
- A VPC with private subnets in at least 2 availability zones and a NAT gateway (or VPC endpoints) for egress to ECR, Secrets Manager, RDS, and OTLP. If you don't have this, see AWS docs on creating a VPC for Fargate. In production we recommend a VPC dedicated to the agent, with peering or Transit Gateway connecting it to the VPCs the agent manages.
- A container image registry to publish a custom formae image to (ECR, GHCR, etc.) — used in Build a custom agent image below.
The walkthrough assumes you've assigned shell variables for the IDs you'll reuse:
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export REGION=<region>
export VPC_ID=<vpc-id>
export PRIVATE_SUBNETS="subnet-...,subnet-..." # 2+ private subnets, different AZs
Set up the database
Direct PostgreSQL — Aurora PostgreSQL is the simplest choice, but any PostgreSQL works (RDS, self-managed). The agent connects over the cluster endpoint, which means the agent must run inside the VPC that hosts the cluster, or have a peering / Transit Gateway path to it.
Create the credentials secret:
aws secretsmanager create-secret \
--name formae-db-creds \
--secret-string '{"username":"postgres","password":"<password>"}' \
--tags Key=app,Value=formae-agent
Create an Aurora PostgreSQL cluster (Serverless v2 shown; provisioned works the same):
aws rds create-db-cluster \
--db-cluster-identifier formae-db \
--engine aurora-postgresql \
--engine-version 16.4 \
--serverless-v2-scaling-configuration MinCapacity=0.5,MaxCapacity=2 \
--master-username postgres \
--master-user-password <password> \
--vpc-security-group-ids <db-sg-id> \
--db-subnet-group-name <db-subnet-group> \
--tags Key=app,Value=formae-agent
aws rds wait db-cluster-available --db-cluster-identifier formae-db
aws rds create-db-instance \
--db-instance-identifier formae-db-instance \
--db-cluster-identifier formae-db \
--db-instance-class db.serverless \
--engine aurora-postgresql \
--tags Key=app,Value=formae-agent
aws rds wait db-instance-available --db-instance-identifier formae-db-instance
The cluster's security group needs ingress on port 5432 from the agent's security group (created in Networking below).
Create the database:
CLUSTER_HOST=$(aws rds describe-db-clusters \
--db-cluster-identifier formae-db \
--query "DBClusters[0].Endpoint" --output text)
PGPASSWORD=<password> psql -h $CLUSTER_HOST -U postgres -d postgres -c "CREATE DATABASE formae;"
Run this from inside the VPC (a bastion or a one-off ECS task), since the cluster endpoint isn't internet-routable.
Important: Aurora's managed master credential rotation can break the agent at the 30-day mark. See Aurora master credential rotation before going to production.
Build a custom agent image
Production deployments should use sslmode=verify-full for the database connection — sslmode=require only encrypts the connection, it doesn't verify the server's identity. Aurora's certificate is signed by Amazon's RDS-specific CA, which isn't in standard system trust stores; bake the AWS RDS Global CA bundle into a custom agent image.
FROM ghcr.io/platform-engineering-labs/formae:<version>
USER root
RUN mkdir -p /opt/pel/certs
ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem \
/opt/pel/certs/rds-global-bundle.pem
RUN chmod 0644 /opt/pel/certs/rds-global-bundle.pem
USER pel
Build and push the image to your registry:
docker build -t <your-registry>/formae:<version>-rds .
docker push <your-registry>/formae:<version>-rds
Pin a specific upstream tag rather than :latest — pinning gives you reproducible deploys and a known-good revision to roll back to. The upstream image is published as a public GHCR package, so no authentication is required for pulls.
If you also need extra plugins in the image, see Custom images with extra plugins and combine the two patterns in a single Dockerfile.
Configure the agent
The agent config is stored in Secrets Manager and injected as an environment variable at runtime. The container's start command writes it to a file before launching the agent.
Create a config file (formae.conf.pkl):
amends "formae:/Config.pkl"
import "plugins:/Aws.pkl" as Aws
agent {
datastore {
datastoreType = "postgres"
postgres {
host = "<cluster-writer-endpoint>.<region>.rds.amazonaws.com"
port = 5432
user = "postgres"
password = "<password>"
database = "formae"
connectionParams = "sslmode=verify-full&sslrootcert=/opt/pel/certs/rds-global-bundle.pem"
}
}
resourcePlugins {
new Aws.PluginConfig {
discoveryFilters = new Listing {
// Exclude the agent's own infrastructure from discovery.
// Everything below is tagged app=formae-agent on create.
new {
resourceTypes = new Listing {
"AWS::IAM::Role"
"AWS::ECS::Cluster"
"AWS::ECS::Service"
"AWS::ECS::TaskDefinition"
"AWS::EC2::SecurityGroup"
"AWS::Logs::LogGroup"
"AWS::RDS::DBCluster"
"AWS::RDS::DBInstance"
"AWS::SecretsManager::Secret"
}
conditions = new Listing {
new {
propertyPath = "$.Tags[?(@.Key=='app')].Value"
propertyValue = "formae-agent"
}
}
}
}
}
}
}
Use the cluster's writer endpoint (not the reader endpoint) — the agent does writes.
Note: If you also have ECS Express Mode services in the same account, add a second filter for AmazonECSManaged=true — see the Express page for the shape.
Note: To export agent metrics, traces, and logs to an OTLP collector, add an oTel block under agent. See Observability.
Important: The default agent config is unauthenticated. Configure basic auth before exposing the agent — add an auth block to both agent and cli config.
Store the config in Secrets Manager:
aws secretsmanager create-secret \
--name formae-config \
--secret-string file://formae.conf.pkl \
--tags Key=app,Value=formae-agent
Create IAM roles
Standard ECS needs two roles: a task execution role to pull images and inject secrets, and a task role for runtime permissions. (No ecsInfrastructureRoleForExpressServices — that's Express-only.)
aws iam create-role --role-name ecsTaskExecutionRole \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ecs-tasks.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}' \
--tags Key=app,Value=formae-agent
aws iam create-role --role-name formae-ecs-task-role \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ecs-tasks.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}' \
--tags Key=app,Value=formae-agent
Attach the managed policy and inline policies:
aws iam attach-role-policy --role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam put-role-policy --role-name ecsTaskExecutionRole \
--policy-name formae-secrets-access \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": [
"arn:aws:secretsmanager:'$REGION':'$ACCOUNT_ID':secret:formae-config*",
"arn:aws:secretsmanager:'$REGION':'$ACCOUNT_ID':secret:formae-db-creds*"
]
}]
}'
aws iam put-role-policy --role-name formae-ecs-task-role \
--policy-name formae-db-creds \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:'$REGION':'$ACCOUNT_ID':secret:formae-db-creds*"
}]
}'
aws iam attach-role-policy --role-name formae-ecs-task-role \
--policy-arn arn:aws:iam::aws:policy/PowerUserAccess
PowerUserAccess covers most AWS services but explicitly excludes IAM operations. Most real deployments need to manage IAM resources, so add an inline policy granting IAM CRUD without AdministratorAccess — see the Express IAM section for the policy document.
The AWS plugin picks up credentials from the task role automatically via the standard AWS credential chain. See the AWS plugin credentials docs for the full chain.
Networking
Create an ECS cluster, a security group for the agent's task, and a log group:
aws ecs create-cluster \
--cluster-name formae \
--capacity-providers FARGATE \
--tags key=app,value=formae-agent
aws ec2 create-security-group \
--group-name formae-agent \
--description "formae agent task" \
--vpc-id $VPC_ID \
--tag-specifications 'ResourceType=security-group,Tags=[{Key=app,Value=formae-agent}]'
Capture the security group ID and grant ingress on port 49684 from your CLI tunnel (e.g. a VPN CIDR, bastion SG, Tailscale subnet) — adjust to your setup:
export AGENT_SG=$(aws ec2 describe-security-groups \
--filters "Name=group-name,Values=formae-agent" "Name=vpc-id,Values=$VPC_ID" \
--query 'SecurityGroups[0].GroupId' --output text)
aws ec2 authorize-security-group-ingress \
--group-id $AGENT_SG \
--protocol tcp --port 49684 \
--cidr <your-tunnel-cidr>
Allow the agent's security group to reach the database security group on 5432:
aws ec2 authorize-security-group-ingress \
--group-id <db-sg-id> \
--protocol tcp --port 5432 \
--source-group $AGENT_SG
Create the agent's log group:
aws logs create-log-group \
--log-group-name /formae/agent \
--tags app=formae-agent
Deploy the agent
Register a task definition pointing at the custom image you built:
export CONFIG_SECRET_ARN=$(aws secretsmanager describe-secret \
--secret-id formae-config --query "ARN" --output text)
aws ecs register-task-definition \
--family formae-agent \
--task-role-arn arn:aws:iam::$ACCOUNT_ID:role/formae-ecs-task-role \
--execution-role-arn arn:aws:iam::$ACCOUNT_ID:role/ecsTaskExecutionRole \
--network-mode awsvpc \
--requires-compatibilities FARGATE \
--cpu 1024 --memory 2048 \
--container-definitions "[{
\"name\": \"agent\",
\"image\": \"<your-registry>/formae:<version>-rds\",
\"portMappings\": [{\"containerPort\": 49684, \"protocol\": \"tcp\"}],
\"secrets\": [
{\"name\": \"FORMAE_CONFIG\", \"valueFrom\": \"$CONFIG_SECRET_ARN\"}
],
\"command\": [\"sh\", \"-c\", \"printenv FORMAE_CONFIG > /tmp/formae.conf.pkl && formae agent start --config /tmp/formae.conf.pkl\"],
\"logConfiguration\": {
\"logDriver\": \"awslogs\",
\"options\": {
\"awslogs-group\": \"/formae/agent\",
\"awslogs-region\": \"$REGION\",
\"awslogs-stream-prefix\": \"agent\"
}
}
}]" \
--tags key=app,value=formae-agent
Adjust --cpu and --memory based on the number of resources you plan to manage — see the agent sizing guide.
Create the service:
aws ecs create-service \
--cluster formae \
--service-name formae-agent \
--task-definition formae-agent \
--desired-count 1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={
subnets=[$PRIVATE_SUBNETS],
securityGroups=[$AGENT_SG],
assignPublicIp=DISABLED
}" \
--tags key=app,value=formae-agent
The agent runs as a single-instance service. There's no high availability today — rolling the agent means a brief outage during task swap (~30-60s).
CLI connectivity
A private-subnet agent isn't reachable from your laptop without tunneling. Common options:
- AWS Client VPN or Site-to-Site VPN — most native; integrates with IAM identity. Adds AWS VPN charges.
- Bastion host with SSH tunneling — minimal extra AWS spend, requires bastion management.
- Tailscale tailnet — register the agent task on a tailnet, point the CLI at the tailnet hostname. Cross-cloud and cross-org friendly. See Tailscale's container docs; the auth key goes into the agent's
FORMAE_CONFIGsecret. - AWS PrivateLink — for cross-account / cross-org scenarios where the agent's API needs to be reached without VPNs or tailnets.
Whichever you pick, the CLI needs a stable hostname for the agent. ECS task IPs change on rolling deploys — so in production you'll want one of:
- An internal Application Load Balancer fronting the service (gives
internal-...elb.amazonaws.com) - ECS Service Connect / AWS Cloud Map for service discovery (gives
formae-agent.<namespace>.local) - A Tailscale machine name that the agent task registers under
- A PrivateLink VPC endpoint service DNS name
The agent serves HTTP on port 49684 by default. For TLS, either front the agent with an internal ALB (recommended — terminates TLS), use a tunnel that provides TLS (Tailscale does), or set agent.server.tlsCert / tlsKey in the config and inject the cert into the container.
Point the CLI at the agent in ~/.config/formae/formae.conf.pkl:
amends "formae:/Config.pkl"
cli {
api {
url = "<agent-hostname-on-your-tunnel>"
port = 49684
}
}
If you enabled basic auth, add a cli.auth block here too.
Verify and apply a forma
From a host with tunnel access to the agent:
curl -s -o /dev/null -w "%{http_code}" http://<agent-hostname>:49684/api/v1/health
# 200
formae status agent
formae apply --mode reconcile your-forma.pkl
The CLI enforces a strict version check against the agent — if the versions differ, install the matching CLI version on your local machine with formae upgrade --version <agent-version>.
Next steps
- Operating the agent — update, rollback, custom images, Aurora rotation gotcha
- Configuration — full
formae.conf.pklschema - Observability — OTel export
- Security and networking — broader security model