AWS App Runner
Prerequisites
- formae CLI installed on your local machine - see local installation
- AWS CLI installed and authenticated
- Docker installed
Set up the database
The agent requires a persistent database. Aurora Serverless v2 with the Data API works well here - App Runner 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.
Create a Secrets Manager secret for the database credentials:
aws secretsmanager create-secret \
--name formae-db-creds \
--secret-string '{"username":"postgres","password":"<password>"}'
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
--enable-http-endpoint turns on the Data API. The agent connects over HTTPS - no VPC connectors or NAT gateways needed.
Add a Serverless v2 instance to the cluster:
aws rds create-db-instance \
--db-instance-identifier formae-db-instance \
--db-cluster-identifier formae-db \
--db-instance-class db.serverless \
--engine aurora-postgresql
Wait for the instance to become available, 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"
Configure the agent
The agent config is stored in Secrets Manager and injected as an environment variable at runtime. App Runner does not support file mounts - the start command writes the config to a file.
Create a config file (formae.conf.pkl):
amends "formae:/Config.pkl"
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>"
}
}
}
Store the config in Secrets Manager:
aws secretsmanager create-secret \
--name formae-config \
--secret-string file://formae.conf.pkl
Push the image to ECR
App Runner can only pull images from ECR. Create a repository and push the formae image from ghcr.io:
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
aws ecr create-repository --repository-name formae
docker pull --platform linux/amd64 \
ghcr.io/platform-engineering-labs/formae:latest
aws ecr get-login-password --region <region> \
| docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.<region>.amazonaws.com
docker tag ghcr.io/platform-engineering-labs/formae:latest \
$ACCOUNT_ID.dkr.ecr.<region>.amazonaws.com/formae:latest
docker push $ACCOUNT_ID.dkr.ecr.<region>.amazonaws.com/formae:latest
Create IAM roles
App Runner needs two roles: an access role to pull images from ECR, and an instance role for runtime permissions.
Access role
Create a trust policy file (apprunner-trust.json):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "build.apprunner.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
aws iam create-role \
--role-name formae-apprunner-access \
--assume-role-policy-document file://apprunner-trust.json
Attach the ECR pull policy:
aws iam attach-role-policy \
--role-name formae-apprunner-access \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess
Instance role
Create a trust policy file (apprunner-instance-trust.json):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "tasks.apprunner.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
aws iam create-role \
--role-name formae-apprunner-instance \
--assume-role-policy-document file://apprunner-instance-trust.json
Create a policy for Aurora Data API access (formae-data-api-policy.json):
{
"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 put-role-policy \
--role-name formae-apprunner-instance \
--policy-name formae-data-api \
--policy-document file://formae-data-api-policy.json
The AWS plugin needs permissions to manage infrastructure. Attach PowerUserAccess for a quick start, or scope it to the services you need:
aws iam attach-role-policy \
--role-name formae-apprunner-instance \
--policy-arn arn:aws:iam::aws:policy/PowerUserAccess
Deploy the agent
App Runner splits start commands by whitespace (exec form), so shell operators don't work directly. The ${IFS} variable is used below to embed spaces into a single token for sh -c:
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
CONFIG_SECRET_ARN=$(aws secretsmanager describe-secret \
--secret-id formae-config \
--query "ARN" --output text)
ACCESS_ROLE_ARN=$(aws iam get-role \
--role-name formae-apprunner-access \
--query "Role.Arn" --output text)
INSTANCE_ROLE_ARN=$(aws iam get-role \
--role-name formae-apprunner-instance \
--query "Role.Arn" --output text)
aws apprunner create-service \
--service-name formae-agent \
--source-configuration "{
\"AuthenticationConfiguration\": {
\"AccessRoleArn\": \"$ACCESS_ROLE_ARN\"
},
\"ImageRepository\": {
\"ImageIdentifier\": \"$ACCOUNT_ID.dkr.ecr.<region>.amazonaws.com/formae:latest\",
\"ImageRepositoryType\": \"ECR\",
\"ImageConfiguration\": {
\"Port\": \"49684\",
\"RuntimeEnvironmentSecrets\": {
\"FORMAE_CONFIG\": \"$CONFIG_SECRET_ARN\"
},
\"StartCommand\": \"sh -c printenv\${IFS}FORMAE_CONFIG>/tmp/formae.conf.pkl&&formae\${IFS}agent\${IFS}start\${IFS}--config\${IFS}/tmp/formae.conf.pkl\"
}
}
}" \
--instance-configuration "{
\"Cpu\": \"1024\",
\"Memory\": \"2048\",
\"InstanceRoleArn\": \"$INSTANCE_ROLE_ARN\"
}" \
--health-check-configuration "{
\"Protocol\": \"HTTP\",
\"Path\": \"/api/v1/health\",
\"Interval\": 10,
\"Timeout\": 5,
\"HealthyThreshold\": 1,
\"UnhealthyThreshold\": 3
}"
RuntimeEnvironmentSecretsinjects the config from Secrets Manager as theFORMAE_CONFIGenv var- The start command writes it to a file before launching the agent
- 1 vCPU / 2 GB - closest to the GCP and Azure configurations
The service takes a few minutes to deploy. Check the status:
aws apprunner describe-service \
--service-arn <service-arn> \
--query "Service.Status"
Verify
Get the service URL:
SERVICE_URL=$(aws apprunner describe-service \
--service-arn <service-arn> \
--query "Service.ServiceUrl" --output text)
curl -s -o /dev/null -w "%{http_code}" https://$SERVICE_URL/api/v1/health
# 200
App Runner provides a public HTTPS endpoint by default - no proxy needed.
Applying formas
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.
Update the CLI's agent URL in ~/.config/formae/formae.conf.pkl:
amends "formae:/Config.pkl"
cli {
api {
url = "https://<service-url>"
port = 443
}
}
Replace <service-url> with the App Runner service URL from the previous step.
Check connectivity:
formae status agent
Then apply a forma:
formae apply --mode reconcile your-forma.pkl
How authentication works
The formae AWS plugin uses the standard AWS credential chain. On App Runner, the instance role's credentials are available automatically - no env vars, no key files, no token refresh to manage.
See the AWS plugin docs for the full credential chain.
Next steps
Head to the quick start or see the configuration docs for available options.