# GitHub Actions runners

Run GitHub Actions jobs on Composal. Choose VM or sandbox runners, use the included tools, or preload your own Docker image.

## Use the included tools

Composal runners include Node 26, AWS CLI v2, GitHub CLI (`gh`) and `jq`.
Use them directly in your workflow steps.
`actions/setup-node` or your project's tool manager can select another Node
version.

## Set up runner access

Install Vex 1.17.90 or newer to manage runners. Sign in and check the pools and
images available to your organization:

```sh
curl -fsSL https://dl.composal.com/install-vex -o /tmp/install-vex.sh
sh /tmp/install-vex.sh
vex login
vex --org acme runner github list
vex --org acme runner images list
vex agent-help
```

Your organization needs GitHub Actions runner access enabled and a GitHub App
connection with access to the repository. Organization owners and administrators
can configure images and pools. A pool serves one exact repository/label pair.

## Choose a VM or sandbox

| Workflow label | Runtime | Choose for |
| --- | --- | --- |
| `composal-vm-x64-4x` | VM | Docker builds, container actions and service containers |
| `composal-sandbox-x64-4x` | Sandbox | Shell and JavaScript actions, lint and tests without Docker |
| `composal-sandbox-x64` | Sandbox, 1 CPU / 2 GiB | Lightweight scripts and tests without Docker |
| `composal-sandbox-x64-0.5x` | Sandbox, 0.5 CPU / 1 GiB | Short scripts, lint and small checks without Docker |
| `composal-x64-4x` | Organization-configured backend | Builds and tests; 4 vCPUs / 16 GiB on Boxmad-enabled pools |
| `composal-gen2-x64-4x` | Nomad / gVisor sandbox | Docker jobs, container actions and service containers on a Boxmad-enabled pool |
| `composal-gen2` | Nomad / gVisor sandbox, 1 vCPU / 2 GiB | Lightweight checks on a Boxmad-enabled pool |

Each label family offers 2x, 4x, 8x and 16x sizes, with 2/4/8/16 physical CPU cores
and 4/16/32/64 GiB RAM, respectively, for Modal pools. Modal sandboxes do not run
a Docker daemon; use a Modal VM for container actions, service containers and
Docker builds. Boxmad labels use 2/4/8/16 requested vCPUs and a private Docker daemon
inside each gVisor sandbox. They require a Boxmad-enabled pool and an approved
Docker-capable runner image.

Standard labels can use Boxmad without a generation suffix. An organization
admin configures the pool backend; the label keeps its CPU and memory size.
Use `composal` for 1 vCPU / 2 GiB, `composal-x64-4x` for 4 vCPUs / 16 GiB,
or `composal-x64-2x-8gb` for 2 vCPUs / 8 GiB. Existing pools keep their
configured backend until an admin changes it. Migrating an existing pool to Boxmad requires all
queued and running jobs to finish and their runners to be cleaned up.
Historical attempts retain their original backend and billing records. A used
Boxmad pool keeps its backend so pending or delayed Spot recovery remains free.
Retained dependency-cache volumes keep their storage measurements after migration;
changing the pool backend does not delete cached data.

Gen2 labels select Boxmad runners. Use `composal-gen2` for 1 vCPU / 2 GiB,
`composal-gen2-x64-2x` for 2 vCPUs / 4 GiB, and the 4x, 8x or 16x suffix
for 4/8/16 vCPUs and 16/32/64 GiB. `composal-gen2-x64-2x-8gb` keeps two
vCPUs with 8 GiB. Existing `composal-boxmad-*` labels remain compatible.

Composal's archive-cache proxy applies to Node actions on the outer runner.
Actions inside a `container:` job use GitHub's standard cache storage. This is
the same cache boundary as Modal VM runners.

Choose one of the smaller sandbox labels for jobs that need fewer resources.
The bare `composal-sandbox-x64` label selects 1 CPU and 2 GiB RAM.
The `composal-sandbox-x64-0.5x` label selects 0.5 CPU and 1 GiB RAM.

## Use a static outbound IP

Open your repository’s **Actions** page or **Organization settings → VCS → Nodes**
and find **Static outbound IP**. Organization owners and admins can select
**Request static IP**. The request remains pending until a Composal admin approves
and provisions the organization’s networking.

Once active, copy the displayed IPv4 CIDR into your services’ IP allowlists.
The setting covers new Gen2 jobs across the organization’s repositories, including
their service containers. The displayed assignment comes from the current routing
policy; an activation request alone does not enable it. Classic runners are outside
this setting.

## Connect runners through AWS PrivateLink

**Admin-managed rollout:** Approved Gen2 organizations can connect native Docker
runners on AWS in Frankfurt (`eu-central-1`) to specific private services. Contact
Composal to request activation. The **AWS PrivateLink** panel on your repository’s
Actions page and organization runner settings shows the endpoint ID, region,
hostname, ports and setup status. Use **Copy ID** to send the endpoint ID to your
service provider for allowlisting.

**Awaiting Customer Allowlisting** and **Awaiting Connectivity Validation** mean
runner access is not enabled. **Enabled for AWS Runners** means an admin has
activated and verified tenant-scoped DNS, routing and AWS-only placement. New
jobs wait for a prepared AWS worker rather than falling back to a worker without
private connectivity. Existing jobs keep their original network until they finish.
Internet traffic keeps the organization’s assigned public egress IP.

Private connectivity is currently qualified for native Docker runners, including
job service containers. VM runners are not supported. Requesting a static outbound
IP does not create a PrivateLink connection.

PrivateLink lets a runner connect to a specific service through private endpoint
addresses. Your service stays in your AWS account. This connection does not grant
the runner access to every resource in your VPC, and it does not replace your
service's authentication or TLS configuration.

### Send your service details

Ask the AWS administrator who owns your service to provide:

| Detail | Example or purpose |
| --- | --- |
| Composal organization and repositories | Identify the CI workloads that need access |
| Endpoint service name | `com.amazonaws.vpce.eu-central-1.vpce-svc-…` |
| AWS region and supported Availability Zone IDs | Confirm compatible worker placement |
| Service ports and protocol | For example, TCP 443 |
| Hostnames used by your tests | For example, `api.internal.example.com` |
| Private DNS configuration | Whether the endpoint service has a verified private DNS name |
| Health check and authentication requirements | Define a safe test that proves the connection |

Send service identifiers and networking requirements, not AWS credentials or
application secrets. Composal will provide the consumer AWS principal ARN and
the endpoint connection ID for your administrator to review.

If you have an existing PrivateLink connection with another CI provider, share
its service name and DNS requirements. That provider's consumer endpoint is not
reused by Composal.

### Approve the connection in AWS

Your AWS administrator allows the exact consumer principal supplied by Composal
on the endpoint service. When connection acceptance is required, they also
approve Composal's specific endpoint request. Review the service's load balancer,
target health and security groups for the intended ports. AWS documents these
steps in [endpoint service permissions and acceptance](https://docs.aws.amazon.com/vpc/latest/privatelink/configure-endpoint-service.html).

Composal configures the consumer endpoint and runner-side DNS. A provider-managed
private DNS name requires domain verification; see [AWS PrivateLink DNS](https://docs.aws.amazon.com/vpc/latest/privatelink/manage-dns-names.html).
Your workflow should use the hostname covered by your service's TLS certificate,
rather than an endpoint IP address.

### Verify before changing existing access

Before customer enablement, Composal must validate organization-scoped access
and required AWS placement. Jobs requiring the connection must wait for eligible
AWS capacity rather than run on a machine without that connection. Classic
runners and Hetzner workers are outside this rollout.

Run a canary with Composal using the agreed runner label. Check private DNS and
an authenticated request to your service. If your workflow uses Docker service
containers, check access from those containers too. Composal must also verify
that an unrelated workload cannot reach the endpoint and that revoking the
connection removes access. Keep your existing network access until your
organization's canary succeeds.

If a request fails, check endpoint acceptance, private DNS resolution, target
health, the permitted port and your application's credentials. PrivateLink
traffic does not use a static public egress IP; public IP allowlists alone do
not establish this connection. Contact Composal with the GitHub run URL and the
failing hostname and port.

## Recover from a Spot interruption

For confirmed AWS Spot interruptions, Composal automatically reruns the affected
Gen2 job after GitHub finishes the interrupted workflow attempt. It retries each
job at most twice. The job starts again from its first step, using the same commit;
GitHub also reruns dependent jobs. Automatic recovery attempts have **zero billable
runner minutes**, including dependent jobs, while their runtime remains visible.
These free attempts can run even when your included CI minutes are exhausted.

The connected GitHub App needs **Actions: Read and write** permission. Approve
GitHub’s permission-update request if the installation currently has read-only
access. Normal test failures and customer cancellations do not trigger recovery.
Jobs with ambiguous duplicate display names require a manual rerun.

## Understand runner usage

Classic runner sizes use physical CPU cores. Usage billing uses vCPUs: each physical core
counts as two vCPUs, so a 4x runner maps to the 8-vCPU CI-minute class. Completed
jobs round up to whole minutes using GitHub's job start and completion times.

Classic and Gen2 Linux x64 compute rates match the published Blacksmith rate of
$0.002 per vCPU-minute. Gen2 bills its requested vCPU count:
$0.004/minute for 2 vCPUs, $0.008 for 4, $0.016 for 8 and $0.032 for 16.
The 1-vCPU runner is $0.002/minute. Each executed job has a one-minute minimum
and rounds up to the next whole minute: a 60-second job bills one minute;
a 61-second job bills two. Provisioning and unused reservations do not count.
Gen2 uses a separate rate class and does not use the standard-runner allowance.
Classic retains the physical-core conversion and its existing allowance terms;
for example, Classic 4x bills the 8-vCPU rate of $0.016/minute. Bursting into
spare CPU capacity does not increase the saved allocation or its charge.

Paid runner add-ons are separate from compute:

| Service | Rate |
| --- | --- |
| Verified static egress IP | $100 per IP per calendar month |
| Retained Docker layer cache | $0.50 per decimal GB per calendar month |
| Persistent dependency disks | $0.50 per decimal GB per calendar month |

Add-ons are prorated from trusted usage observations. Cache storage continues
while retained, including when its builder sleeps; unused sparse disk capacity
is not billed. Jobs and presets sharing one dependency disk do not multiply its
storage charge. The regular Actions cache, checkout cache and ordinary runner
scratch disks have no additional runner add-on charge. PrivateLink is not a
static-IP purchase. Static IPs still require an administrator to enable them.
Existing negotiated prices and allowances take precedence. These rates do not
add a new free-minute allowance.

The 1-CPU sandbox uses the 2-vCPU class; the 0.5-CPU sandbox uses the 1-vCPU class.

Composal saves the CPU and memory allocation when it launches a runner. Changing
a pool later does not change that job's recorded resources. CI minutes cover the
runner allocation; RAM is recorded with the job and has no separate compute charge.

## Configure a runner pool

Configure a pool to use the standard tools:

```sh
vex --org acme runner github configure acme/monorepo \
  --label composal-sandbox-x64-0.5x --default-image --max-concurrency 8
```

Then select that exact label in GitHub Actions:

```yaml
jobs:
  check:
    runs-on: composal-sandbox-x64-0.5x
    steps:
      - uses: actions/checkout@v7
      - run: node --version && aws --version && gh --version && jq --version
```

## Reuse Git objects between jobs

Use [Composal Checkout](https://github.com/ComposalAI/checkout) on a Composal
runner to reuse Git objects across jobs:

```yaml
permissions:
  contents: read

jobs:
  test:
    runs-on: composal-sandbox-x64-4x
    steps:
      - uses: composalai/checkout@1
      - run: npm ci && npm test
```

The working tree and `.git` metadata stay on local runner disk. Composal mounts
a repository-scoped Git object cache read-only. Warm checkouts of an exact
commit read those objects without a network fetch. New commits add missing
objects to the cache through a separate preparation worker.

Standard checkout inputs work, including `ref`, `path`, `fetch-depth`, tags and
sparse checkout. The action waits up to 10 seconds for preparation before
fetching from GitHub. If preparation is still pending, it keeps any completed
cached history attached and fetches missing data from GitHub. Preparation
continues for later jobs.

Set `cache-wait-seconds` between 0 and 300 to change the wait. Use `0` to start
the GitHub fetch immediately, reusing mounted objects when available.

The log message `partial Git object cache` means completed history remains
attached for the GitHub fetch. In that case, `cache-hit: false` and
`checkout-source: github` describe the missing objects and their transport.
The message `cache unavailable` means no usable object cache was attached.

All jobs in the same GitHub installation and repository can read cached source.
Workflow code cannot write to this cache. Only objects confirmed as readable
from the GitHub repository enter it; unpublished platform refs and credentials
are excluded.

SSH, Git LFS, submodules, other repositories and runners without the mount use
the standard GitHub transport. If a later container action needs Git access but
cannot see the host mount, use `dissociate: true` to copy the needed objects to
the local checkout. See the action's [input guide](https://github.com/ComposalAI/checkout#cache-behavior)
for examples and outputs.

## Mount dependency caches

Mount persistent storage at a package cache directory with a stable key:

```yaml
- uses: composalai/disk-volume@v1.2.0
  with:
    key: npm-store-v1
    path: ~/.npm
- run: npm ci
```

Package managers use the mounted directory in place. The post step flushes
writes; there is no archive restore, copy-back or automatic cache cleanup.
The checkout and workspace stay on local runner disk; this action mounts only
the dependency cache path you specify.

Keys are scoped to the GitHub installation and repository. Jobs with the same
key share a writable cache across branches and runner sizes, including pull
requests and failed jobs. Use download stores with package integrity checks,
such as npm, pnpm and Cargo caches. Keep credentials and build outputs out of
these directories. Choose separate keys for tools that require exclusive access.

The action accepts an empty absolute, workspace-relative or `~/` path, with up
to five mounts per job. `cache-hit: true` means the directory exists; the package
manager still installs missing dependencies. The first run after this storage
update starts cold.

## Cache Turborepo builds

Serve task artifacts over localhost from the mounted persistent cache:

```yaml
- uses: composalai/turborepo@v0
- run: npm ci
- run: npx turbo run build
```

The action exports the Turbo remote cache settings and flushes artifact writes
when the job ends. Configure task `outputs` in `turbo.json` so Turbo can restore
build files. Artifacts are separated by repository, Git ref and optional `key`
(default `turborepo-v0`). Jobs on the same ref share artifacts; other refs start cold.
There is no automatic eviction. For untrusted workflows, use Turbo artifact
signatures: the mounted volume is writable by workflow code. See the
[Turborepo cache guide](/docs/turborepo-cache) for a complete workflow, task output
configuration, signed artifacts, and troubleshooting.

## Preload your own tools

For additional tools or pinned dependencies, write a linux/amd64 Ubuntu 24.04
Dockerfile with Node 22 or newer on PATH. Custom images keep their own Node
version. Keep credentials out of image layers. For example:

```dockerfile
FROM node:24-bookworm-slim AS node
FROM ubuntu:24.04
COPY --from=node /usr/local/ /usr/local/
RUN apt-get update && apt-get install -y ca-certificates curl git gh jq
```

Build and push it to your registry. Register the immutable digest printed by the
push, replacing the placeholder with its real 64-character SHA-256:

```sh
docker buildx build --platform linux/amd64 \
  --tag ghcr.io/acme/actions-tools:v1 --push .
vex --org acme runner images create tools-v1 \
  --image ghcr.io/acme/actions-tools@sha256:<64-hex-digest> --wait
vex --org acme runner github configure acme/monorepo \
  --label composal-sandbox-x64-4x --image tools-v1
```

Composal prepares the image once, adding the Actions runner and its supporting
tools. Preparation moves through `pending` and `building`, then ends at `ready`
or `failed`. New runners reuse the prepared image. Composal replaces `CMD` and
`ENTRYPOINT`, so put permanent setup in Dockerfile `RUN` instructions.

## Pull from a private registry

Private registries use `--registry-secret RUNNER_REGISTRY`. This names an active
organization secret exposed to CI whose JSON value contains exactly
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD`, both nonempty strings. Store the
value using secret management with stdin or a private file. Composal uses these credentials
to pull the image; it does not add them to workflow environments. Registration does not grant
registry access.

## Update tools or restore the defaults

Inspect preparation and retry a failed build:

```sh
vex --org acme runner images get tools-v1 --json
vex --org acme runner images prepare tools-v1
vex --org acme runner images wait tools-v1 --json
```

Ready images are immutable. Publish a new digest and image name to update tools.
Images are scoped to their organization. Switching a pool affects new runners;
running jobs finish on their original image. A pool waits for its selected image
to become ready. Verify a real GitHub job receives a `composal-gh-*` runner,
prints the expected tool versions, and reaches a terminal result.

Use `--default-image` to restore the standard toolchain. Omit both image flags
to retain the current selection. Configuring a label does not rewrite workflows
or disable other pools; each pool has its own concurrency limit.
