AWS side
IAM policy for the tool's role, the runner-instance role for PassRole, and the AMI / VPC prerequisites for the launch templates the tool will materialize.
The tool runs under one assumed IAM role. That role needs permission to:
- create launch templates that carry the
gha:managed-by=pacertag, and modify only the launch templates that already carry it - describe images, subnets, security groups (so it can validate user input before persisting a pool)
- describe spot price history (for the at-launch cost snapshot — optional, see below)
- read on-demand pricing from
pricing:GetProducts(also optional, see below) - run instances from a tagged launch template, producing instances + volumes that are themselves tagged
- terminate instances only when they carry the tool’s
gha:managed-bytag (the privilege-escalation gate) - pass exactly one role to EC2 (the runner-instance role, not a powerful one)
The tag-based scoping is defense-in-depth: even if the credentials leak, the role can only modify resources the tool itself created (or attaches the tag to during creation).
1. Pick the runner-instance role
Spawned EC2 instances may have their own instance profile — the role they run under, separate from the tool’s role.
This step is optional. If your CI workflows are self-contained (no S3 / ECR / Secrets Manager / Parameter Store / cross-account hops from inside the runner), you can launch instances with no instance profile at all. The pool’s " IAM instance profile" field accepts blank, and the orchestrator’s
iam:PassRolepermission isn’t exercised. Skip the rest of this section and go straight to step 2 with the role field blank in the policy generator.
What the runner-instance role needs depends on what your CI does. At minimum, it needs whatever the workflows themselves want (S3 access, ECR pulls, etc). The GitHub Actions runner binary is downloaded from GitHub releases at boot — that’s a public download and doesn’t need any AWS permission.
A minimal runner-instance role is no AWS permissions, just a trust policy that allows EC2 to assume it. This is the safest default — if a workflow needs AWS access, attach the specific permissions it requires.
The tool’s IAM policy scopes iam:PassRole to this role only. Pick the role name now; you’ll plug it into the
policy template in step 2.
Role vs. instance profile. EC2 launches instances with an instance profile, which is a separate IAM object that wraps the role. They commonly share the same name, but they’re not the same thing. After creating the role, run
aws iam create-instance-profile --instance-profile-name <name>andaws iam add-role-to-instance-profile --instance-profile-name <name> --role-name <name>so the wrapper exists.
2. Apply the tool’s IAM policy
Easiest path: open the interactive IAM policy builder, fill in your account ID + region ( and runner-instance role name, if you decided to use one in step 1), and copy or download the generated JSON. The builder is a static page — values never leave your browser.
If you’d rather work from the canonical template directly, it ships with the repo at docs/iam-role.json — copy that file rather than
transcribing the JSON below, since the canonical version is the one we keep in sync with the actual AWS calls the binary
makes.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescribeForValidation",
"Effect": "Allow",
"Action": [
"ec2:DescribeImages",
"ec2:DescribeSubnets",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSpotPriceHistory"
],
"Resource": "*"
},
{
"Sid": "ReadOnDemandPricing",
"Effect": "Allow",
"Action": "pricing:GetProducts",
"Resource": "*"
},
{
"Sid": "ValidateInstanceProfileAtPoolSave",
"Effect": "Allow",
"Action": "iam:GetInstanceProfile",
"Resource": "arn:aws:iam::REPLACE_ACCOUNT_ID:instance-profile/*"
},
{
"Sid": "CreateTaggedLaunchTemplate",
"Effect": "Allow",
"Action": "ec2:CreateLaunchTemplate",
"Resource": "arn:aws:ec2:REPLACE_AWS_REGION:*:launch-template/*",
"Condition": {
"StringEquals": {
"aws:RequestTag/gha:managed-by": "pacer"
}
}
},
{
"Sid": "ModifyOnlyOurLaunchTemplates",
"Effect": "Allow",
"Action": [
"ec2:CreateLaunchTemplateVersion",
"ec2:ModifyLaunchTemplate",
"ec2:DeleteLaunchTemplate",
"ec2:DeleteLaunchTemplateVersions"
],
"Resource": "arn:aws:ec2:REPLACE_AWS_REGION:*:launch-template/*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/gha:managed-by": "pacer"
}
}
},
{
"Sid": "RunInstancesReadOnlyResources",
"Effect": "Allow",
"Action": "ec2:RunInstances",
"Resource": [
"arn:aws:ec2:REPLACE_AWS_REGION::image/*",
"arn:aws:ec2:REPLACE_AWS_REGION:*:subnet/*",
"arn:aws:ec2:REPLACE_AWS_REGION:*:security-group/*",
"arn:aws:ec2:REPLACE_AWS_REGION:*:network-interface/*",
"arn:aws:ec2:REPLACE_AWS_REGION:*:key-pair/*",
"arn:aws:ec2:REPLACE_AWS_REGION:*:placement-group/*"
]
},
{
"Sid": "RunInstancesFromOurLaunchTemplate",
"Effect": "Allow",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:REPLACE_AWS_REGION:*:launch-template/*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/gha:managed-by": "pacer"
}
}
},
{
"Sid": "RunInstancesTaggedInstanceAndVolume",
"Effect": "Allow",
"Action": "ec2:RunInstances",
"Resource": [
"arn:aws:ec2:REPLACE_AWS_REGION:*:instance/*",
"arn:aws:ec2:REPLACE_AWS_REGION:*:volume/*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/gha:managed-by": "pacer"
}
}
},
{
"Sid": "CreateFleetFromOurLaunchTemplate",
"Effect": "Allow",
"Action": "ec2:CreateFleet",
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestTag/gha:managed-by": "pacer"
}
}
},
{
"Sid": "TagOnCreate",
"Effect": "Allow",
"Action": "ec2:CreateTags",
"Resource": "arn:aws:ec2:REPLACE_AWS_REGION:*:*",
"Condition": {
"StringEquals": {
"ec2:CreateAction": [
"RunInstances",
"CreateFleet",
"CreateLaunchTemplate",
"CreateLaunchTemplateVersion"
],
"aws:RequestTag/gha:managed-by": "pacer"
}
}
},
{
"Sid": "TagAfterFleetLaunch",
"Effect": "Allow",
"Action": "ec2:CreateTags",
"Resource": [
"arn:aws:ec2:REPLACE_AWS_REGION:*:instance/*",
"arn:aws:ec2:REPLACE_AWS_REGION:*:volume/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/gha:managed-by": "pacer"
}
}
},
{
"Sid": "TerminateOnlyOurInstances",
"Effect": "Allow",
"Action": "ec2:TerminateInstances",
"Resource": "arn:aws:ec2:REPLACE_AWS_REGION:*:instance/*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/gha:managed-by": "pacer"
}
}
},
{
"Sid": "PassRunnerInstanceProfile",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::REPLACE_ACCOUNT_ID:role/REPLACE_RUNNER_INSTANCE_ROLE",
"Condition": {
"StringEquals": {
"iam:PassedToService": "ec2.amazonaws.com"
}
}
}
]
}
Replace the placeholders before applying:
| Placeholder | What to put |
|---|---|
REPLACE_AWS_REGION | The AWS region the tool runs in (matches aws.region in the YAML config). Restricting the role to a single region prevents cross-region abuse. |
REPLACE_ACCOUNT_ID | Your AWS account ID (12 digits). |
REPLACE_RUNNER_INSTANCE_ROLE | The IAM role attached to the runner instance profile (from step 1). |
The literal pacer value of the gha:managed-by tag is hard-coded in the binary (
internal/core/ec2lt/ec2lt.go::ManagedByTagValue); do not change it unless you also fork the binary.
What each statement does
| Sid | What it allows |
|---|---|
DescribeForValidation | Validate the AMI / subnet / security-group IDs an operator types into a pool form before persisting the pool. Read-only. |
ReadOnDemandPricing | Optional. Powers the cost-tracking feature. Drop this if you don’t need at-launch cost snapshots; the orchestrator logs a WARN and stamps NULL prices. |
ValidateInstanceProfileAtPoolSave | Lets the tool call iam:GetInstanceProfile at pool save time so a missing or empty instance profile fails fast in the form instead of 30s later at the first spawn. Optional — without it the pool save still proceeds; the operator just sees the cryptic EC2 error at spawn time. |
CreateTaggedLaunchTemplate | Create new LTs only when the request stamps gha:managed-by=pacer on the LT itself. The tool always does. |
ModifyOnlyOurLaunchTemplates | Bump versions / set defaults / delete versions on LTs that already carry the tag. The Fleet path mints a transient LT version per spawn (carrying per-job user-data) and deletes it after; this Sid covers both. |
RunInstancesReadOnlyResources | Grants RunInstances against the read-only inputs (AMI, subnet, SG, ENI, etc.). No tag condition because these are not tool-owned. |
RunInstancesFromOurLaunchTemplate | Limits which LT can drive a RunInstances call to those carrying the tool’s tag — defends against using a random LT. |
CreateFleetFromOurLaunchTemplate | Allows CreateFleet only when the request stamps gha:managed-by=pacer on the fleet itself (the orchestrator does). The fleet’s LaunchTemplateConfigs reference the pool’s LT, which is also gated by the same tag. |
RunInstancesTaggedInstanceAndVolume | Requires the produced instance + volume be stamped with the tool’s tag at create time. |
TagOnCreate | Permits ec2:CreateTags only as part of RunInstances / CreateFleet / CreateLaunchTemplate*, and only when the tool’s tag is in the request. |
TagAfterFleetLaunch | The Fleet path can’t carry per-job tags through CreateFleet’s API surface. The orchestrator post-tags instances with gha:job_id, gha:repo, and the repo user-tag layer. The condition pins this to instances/volumes that already carry gha:managed-by=pacer (placed there by the LT at launch). |
TerminateOnlyOurInstances | Tag-scoped Terminate. The role can only kill instances the tool itself launched. |
PassRunnerInstanceProfile | The privilege-escalation gate. Scoped to a single role ARN — only the runner-instance role from step 1 can be passed to EC2. |
Apply the policy as either:
- An inline policy on an IAM role you create for the tool, then run the tool host with that role attached (EC2 instance profile, ECS task role, etc).
- A standalone policy + an IAM user with
aws_access_key_id/aws_secret_access_key, configured via~/.aws/credentialsand pointed to inaws.profile.
3. Pick networking + AMI
Before you can save your first pool, you need:
- One or more VPC subnet IDs — instances launch here. List subnets across multiple AZs; the default
fleetspawn method passes every (instance_type × subnet) combo to AWS and picks an available one. The legacyrun_instancesspawn method uses only the first subnet. - A security group ID — at minimum, allow outbound HTTPS to
api.github.comand to yourserver.public_url. No inbound is needed (the runner connects out, not in). - An AMI ID — must include
bash,curl,jq,tar, andsudo. The user-data script downloadsactions/runnerfrom GitHub at boot (cached on the EBS volume after the first run); pre-baking it on the AMI is optional and only saves a few seconds. Use therunner_versionfield on the pool to pin a specific runner release; leave it empty to track whatever the server resolved asactions/runner’s latest tag.
Single region. The AWS region is configured globally in YAML (
aws.region). Per-project regions are out of scope. All pools you create go into this one region.
Root volume size. Pool form’s
root_volume_gb=0means “inherit the AMI’s native size”. Any positive value must be >= the AMI’s snapshot size or the pool save fails fast (EC2 won’t launch otherwise).
4. Decide on server.public_url
The user-data script that boots inside each spawned EC2 calls back to the tool over HTTPS:
POST <server.public_url>/api/runner/register
POST <server.public_url>/api/runner/complete
server.public_url must be:
- reachable from the runner subnets (so the spawned EC2 can
curlit) - HTTPS (ideally) — the calls carry HMAC tokens, but TLS is still preferred to avoid token replay risk on shared networks
- the same value you put in the GitHub App’s Webhook URL in the GitHub side (the tool exposes
/api/webhookfrom the same server)
Two options for terminating TLS:
- In-process — set
server.tls.modein the YAML config:manual(operator PEMs),self(self-signed; dev / private), oracme(Let’s Encrypt via HTTP-01). See the server-side guide for the full schema. - Reverse proxy / load balancer — set
server.tls.mode: noneand front the tool with an ALB / Cloudflare / nginx that terminates TLS upstream. ACME mode is for terminating in-process; if you’re behind a load balancer, leave itnone.
What’s next
- Server side — install the binary, write the YAML config, run it.