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
  }"
  • RuntimeEnvironmentSecrets injects the config from Secrets Manager as the FORMAE_CONFIG env 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.