Installation

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=pacer tag, 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-by tag (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:PassRole permission 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> and aws 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:

PlaceholderWhat to put
REPLACE_AWS_REGIONThe 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_IDYour AWS account ID (12 digits).
REPLACE_RUNNER_INSTANCE_ROLEThe 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

SidWhat it allows
DescribeForValidationValidate the AMI / subnet / security-group IDs an operator types into a pool form before persisting the pool. Read-only.
ReadOnDemandPricingOptional. 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.
ValidateInstanceProfileAtPoolSaveLets 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.
CreateTaggedLaunchTemplateCreate new LTs only when the request stamps gha:managed-by=pacer on the LT itself. The tool always does.
ModifyOnlyOurLaunchTemplatesBump 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.
RunInstancesReadOnlyResourcesGrants RunInstances against the read-only inputs (AMI, subnet, SG, ENI, etc.). No tag condition because these are not tool-owned.
RunInstancesFromOurLaunchTemplateLimits which LT can drive a RunInstances call to those carrying the tool’s tag — defends against using a random LT.
CreateFleetFromOurLaunchTemplateAllows 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.
RunInstancesTaggedInstanceAndVolumeRequires the produced instance + volume be stamped with the tool’s tag at create time.
TagOnCreatePermits ec2:CreateTags only as part of RunInstances / CreateFleet / CreateLaunchTemplate*, and only when the tool’s tag is in the request.
TagAfterFleetLaunchThe 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).
TerminateOnlyOurInstancesTag-scoped Terminate. The role can only kill instances the tool itself launched.
PassRunnerInstanceProfileThe 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/credentials and pointed to in aws.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 fleet spawn method passes every (instance_type × subnet) combo to AWS and picks an available one. The legacy run_instances spawn method uses only the first subnet.
  • A security group ID — at minimum, allow outbound HTTPS to api.github.com and to your server.public_url. No inbound is needed (the runner connects out, not in).
  • An AMI ID — must include bash, curl, jq, tar, and sudo. The user-data script downloads actions/runner from 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 the runner_version field on the pool to pin a specific runner release; leave it empty to track whatever the server resolved as actions/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=0 means “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 curl it)
  • 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/webhook from the same server)

Two options for terminating TLS:

  • In-process — set server.tls.mode in the YAML config: manual (operator PEMs), self (self-signed; dev / private), or acme (Let’s Encrypt via HTTP-01). See the server-side guide for the full schema.
  • Reverse proxy / load balancer — set server.tls.mode: none and 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 it none.

What’s next

  • Server side — install the binary, write the YAML config, run it.