AWS ECS Express Mode

This page walks through deploying the formae agent on Amazon ECS Express Mode — the fastest way to get an agent running in AWS. Express auto-provisions the load balancer, target group, listener, security groups, log group, and rollback alarm; you supply the database, configuration, IAM roles, and the agent task.

For production deployments, see the Standard ECS walkthrough. For background on the trade-offs, see the AWS installation overview.

Prerequisites

  • formae CLI installed on your local machine — see local installation
  • AWS CLI v2.34 or later installed and authenticated (aws ecs create-express-gateway-service requires this version)

Set up the database

The agent requires a persistent datastore. Aurora Serverless v2 with the Data API works well here — ECS can reach it over HTTPS without VPC connectivity.

If you already have an Aurora cluster with the Data API enabled, create a database and skip to configure the agent.

Note: Aurora's Data API has a 1 MB cap on both request and response payloads. Agents managing more than ~100 resources, or resources with large property payloads (rich Pkl configs, embedded JSON, dashboard definitions), will hit this ceiling and start failing operations. For production-scale workloads, use the Standard ECS path with a direct PostgreSQL connection instead.

Create a Secrets Manager secret for the database credentials:

aws secretsmanager create-secret \
  --name formae-db-creds \
  --secret-string '{"username":"postgres","password":"<password>"}' \
  --tags Key=app,Value=formae-agent

Create an Aurora Serverless v2 PostgreSQL cluster:

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> \
  --enable-http-endpoint \
  --tags Key=app,Value=formae-agent

--enable-http-endpoint turns on the Data API. The agent connects over HTTPS — no VPC connectors or NAT gateways needed.

Wait for the cluster to become available, then add a Serverless v2 instance:

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

Wait for the instance to become available before creating the database — the Data API requires a running instance, not just an available cluster:

aws rds wait db-instance-available --db-instance-identifier formae-db-instance

Then create the database:

CLUSTER_ARN=$(aws rds describe-db-clusters \
  --db-cluster-identifier formae-db \
  --query "DBClusters[0].DBClusterArn" --output text)

SECRET_ARN=$(aws secretsmanager describe-secret \
  --secret-id formae-db-creds \
  --query "ARN" --output text)

aws rds-data execute-statement \
  --resource-arn $CLUSTER_ARN \
  --secret-arn $SECRET_ARN \
  --sql "CREATE DATABASE formae"

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.

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). The discoveryFilters block tells the AWS plugin to exclude the agent's own infrastructure from its periodic discovery scans — without it, every scan would surface the ALB, target group, log group, IAM roles, ECS service, and so on as "unmanaged" resources.

amends "formae:/Config.pkl"

import "plugins:/Aws.pkl" as Aws

agent {
    datastore {
        datastoreType = "auroradataapi"
        auroraDataAPI {
            clusterArn = "arn:aws:rds:<region>:<account-id>:cluster:formae-db"
            secretArn  = "arn:aws:secretsmanager:<region>:<account-id>:secret:formae-db-creds"
            database   = "formae"
            region     = "<region>"
        }
    }

    resourcePlugins {
        new Aws.PluginConfig {
            discoveryFilters = new Listing {
                // Express auto-provisioned (ALB, target group, listener,
                // security groups, log group, rollback alarm) — AWS stamps
                // these with the AmazonECSManaged=true system tag.
                new {
                    resourceTypes = new Listing {
                        "AWS::ElasticLoadBalancingV2::LoadBalancer"
                        "AWS::ElasticLoadBalancingV2::TargetGroup"
                        "AWS::ElasticLoadBalancingV2::Listener"
                        "AWS::EC2::SecurityGroup"
                        "AWS::Logs::LogGroup"
                        "AWS::CloudWatch::Alarm"
                    }
                    conditions = new Listing {
                        new {
                            propertyPath  = "$.Tags[?(@.Key=='AmazonECSManaged')].Value"
                            propertyValue = "true"
                        }
                    }
                }
                // Resources you create yourself — tagged app=formae-agent
                // on create below.
                new {
                    resourceTypes = new Listing {
                        "AWS::IAM::Role"
                        "AWS::ECS::Service"
                        "AWS::ECS::TaskDefinition"
                        "AWS::RDS::DBCluster"
                        "AWS::RDS::DBInstance"
                        "AWS::SecretsManager::Secret"
                    }
                    conditions = new Listing {
                        new {
                            propertyPath  = "$.Tags[?(@.Key=='app')].Value"
                            propertyValue = "formae-agent"
                        }
                    }
                }
            }
        }
    }
}

The shared default ECS cluster is intentionally left unfiltered — Express deploys into it by default, but other workloads may share it.

Note: To export agent metrics, traces, and logs to an OTLP collector, add an oTel block under agent. See Observability for the configuration reference.

Important: The default agent config is unauthenticated. Before exposing the agent on a public ALB, configure basic auth — 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

ECS Express Mode needs three roles: a task execution role to pull images and inject secrets, an infrastructure role to manage the load balancer and networking, and a task role for runtime permissions.

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 ecsInfrastructureRoleForExpressServices \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"Service": "ecs.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 policies:

aws iam attach-role-policy --role-name ecsTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

aws iam attach-role-policy --role-name ecsInfrastructureRoleForExpressServices \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices

The execution role needs access to the config secret:

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*"
    }]
  }'

The task role needs Aurora Data API access and permissions for the AWS plugin to manage infrastructure:

aws iam put-role-policy --role-name formae-ecs-task-role \
  --policy-name formae-data-api \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Action": [
          "rds-data:ExecuteStatement",
          "rds-data:BeginTransaction",
          "rds-data:CommitTransaction",
          "rds-data:RollbackTransaction"
        ],
        "Resource": "arn:aws:rds:<region>:<account-id>:cluster:formae-db"
      },
      {
        "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. If your formas include IAM resources (roles, policies, users, instance profiles) — which most real deployments do — add the following inline policy:

aws iam put-role-policy --role-name formae-ecs-task-role \
  --policy-name formae-iam-management \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": [
        "iam:CreateRole", "iam:DeleteRole", "iam:GetRole", "iam:ListRoles",
        "iam:UpdateRole", "iam:UpdateAssumeRolePolicy", "iam:TagRole", "iam:UntagRole",
        "iam:PutRolePolicy", "iam:DeleteRolePolicy", "iam:GetRolePolicy", "iam:ListRolePolicies",
        "iam:AttachRolePolicy", "iam:DetachRolePolicy", "iam:ListAttachedRolePolicies",
        "iam:CreatePolicy", "iam:DeletePolicy", "iam:GetPolicy", "iam:ListPolicies",
        "iam:CreatePolicyVersion", "iam:DeletePolicyVersion", "iam:GetPolicyVersion",
        "iam:ListPolicyVersions", "iam:SetDefaultPolicyVersion",
        "iam:CreateInstanceProfile", "iam:DeleteInstanceProfile", "iam:GetInstanceProfile",
        "iam:AddRoleToInstanceProfile", "iam:RemoveRoleFromInstanceProfile",
        "iam:CreateUser", "iam:DeleteUser", "iam:GetUser", "iam:TagUser", "iam:UntagUser",
        "iam:CreateGroup", "iam:DeleteGroup", "iam:GetGroup",
        "iam:PassRole"
      ],
      "Resource": "*"
    }]
  }'

This grants IAM CRUD without granting the broader AdministratorAccess, which would let the agent modify its own permissions or touch Organizations.

The AWS plugin picks up credentials from the task role automatically via the standard AWS credential chain — no env vars, key files, or token refresh to manage. See the AWS plugin credentials docs for the full chain.

Deploy the agent

Pin a specific image tag rather than :latest — pinning gives you reproducible deploys and a known-good revision to roll back to. Find the latest stable tag at GitHub Releases and substitute it for <version> below. The image is published as a public package, so no GHCR authentication is required.

CONFIG_SECRET_ARN=$(aws secretsmanager describe-secret \
  --secret-id formae-config \
  --query "ARN" --output text)

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

aws ecs create-express-gateway-service \
  --service-name formae-agent \
  --execution-role-arn arn:aws:iam::$ACCOUNT_ID:role/ecsTaskExecutionRole \
  --infrastructure-role-arn arn:aws:iam::$ACCOUNT_ID:role/ecsInfrastructureRoleForExpressServices \
  --task-role-arn arn:aws:iam::$ACCOUNT_ID:role/formae-ecs-task-role \
  --primary-container "{
    \"image\": \"ghcr.io/platform-engineering-labs/formae:<version>\",
    \"containerPort\": 49684,
    \"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\"]
  }" \
  --health-check-path "/api/v1/health" \
  --cpu 1024 \
  --memory 2048 \
  --tags key=app,value=formae-agent

The agent listens on port 49684 by default; the ALB terminates TLS on 443 and forwards to the container port. Adjust --cpu and --memory based on the number of resources you plan to manage — see the agent sizing guide.

ECS Express Mode auto-provisions the load balancer, TLS certificate, networking, and auto-scaling. The service URL is in the response under activeConfigurations[0].ingressPaths[0].endpoint.

Verify

curl -s -o /dev/null -w "%{http_code}" https://<service-url>/api/v1/health
# 200

Apply a forma

The formae CLI runs on your local machine. It evaluates your Pkl files locally and sends the result to the agent API — no files need to be loaded into the container.

Point the CLI at the agent in ~/.config/formae/formae.conf.pkl:

amends "formae:/Config.pkl"

cli {
    api {
        url  = "https://<service-url>"
        port = 443
    }
}

If you enabled basic auth, add a cli.auth block here too.

Check connectivity, then apply a forma:

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

Migrating to Standard ECS

Express Mode and Standard ECS deploy different ECS service shapes, so there's no in-place upgrade. To move:

  1. Stand up a Standard ECS deployment in a new VPC, pointing at the existing Aurora cluster (with datastoreType = "postgres" instead of "auroradataapi") or a fresh database.
  2. Once the new agent is healthy, repoint the CLI and tear down the Express service.

The IAM roles, secrets, and database can be reused.