Developer Tools AWS

GitHub Actions Runner on AWS User Guide

| Product: GitHub Actions Runner on AWS

Overview

This image runs the official self hosted runner agent for GitHub Actions, the same actions/runner agent GitHub publishes, installed from the pinned and checksum verified upstream release.

A self hosted runner lets you execute your GitHub Actions workflows on machines you control rather than on GitHub hosted runners. That is what you need when a job must reach a private network, use hardware or software the hosted runners do not offer, run inside your own compliance boundary, or simply run for longer or more often than your hosted minutes allow.

The runner works by opening an outbound long poll connection to GitHub and waiting to be handed a job. That has a useful consequence for your network design: the runner never listens for inbound CI traffic, so this image opens no inbound port other than SSH for your own management. There is nothing to publish to the internet and nothing to put behind a load balancer.

The runner is installed at /opt/actions-runner and runs as a dedicated non root system account, ghrunner, which has no password and no sudo rights. A systemd unit, github-actions-runner.service, is installed and ready but is deliberately left disabled.

This image ships with no credentials, on purpose

There is no GitHub token in this image and no runner registration of any kind. That is a deliberate security decision rather than an omission.

A runner registration token is short lived and specific to your repository or organisation, and the long lived runner credentials it is exchanged for grant the holder the ability to pick up your jobs. An image that shipped either would let anyone who launched it join your continuous integration and execute against your repositories. So the image ships the runner software only, and you perform the registration once, with a token you generate yourself.

Because there is nothing to run until you register, the runner service is left disabled and is additionally gated: its unit carries ConditionPathExists=/opt/actions-runner/.runner, the state file that registration creates. Until you register, even an accidental systemctl start is a harmless no operation rather than a crash loop.

On first boot the instance generates a unique runner name for itself, so several instances launched from this image can join the same organisation without colliding, and writes that identity plus the exact registration commands to /root/github-actions-runner-credentials.txt with mode 0600 so only root can read it.

Runner version and per instance identity

Prerequisites

Before you deploy this image you need:

  • An Amazon Web Services account where you can launch EC2 instances
  • IAM permissions to launch instances, create security groups, and subscribe to AWS Marketplace products
  • An SSH key pair in the region you intend to launch in
  • A GitHub account with permission to add a self hosted runner to the repository, organisation or enterprise you intend to serve. Adding a runner to a repository requires admin on that repository; adding one to an organisation requires organisation owner

Launching the instance

You can launch from the AWS Marketplace console or from the AWS CLI.

From the console, subscribe to the product, choose Launch through EC2, pick your instance type and key pair, and select a security group that allows inbound TCP 22 from your own management address only. No other inbound port is required.

From the CLI, substitute the AMI id shown on the product page, your key name, subnet and security group:

aws ec2 run-instances \
  --image-id ami-EXAMPLE0000000000 \
  --instance-type m5.large \
  --key-name your-key-name \
  --security-group-ids sg-EXAMPLE00000000 \
  --subnet-id subnet-EXAMPLE00000 \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=actions-runner}]'

Choosing an instance type

The listing recommends m5.large (2 vCPU, 8 GiB), which is a sensible general purpose starting point and is available in every commercial region.

Size this to your workload, not to the idle runner. A runner sitting idle costs almost nothing in CPU, but a runner doing real work compiles code, runs test suites and builds container images. If your builds are heavy, move up to m5.xlarge or m5.2xlarge, or run several smaller runners in parallel and let GitHub distribute jobs across them. Give / enough room for your build working set: the runner's _work directory holds a full checkout plus build artefacts for every job.

Connecting to your instance

Connect over SSH on port 22 using the key pair you selected at launch. The login user depends on the operating system variant you launched:

OS variant SSH login user
Ubuntu 24.04 LTS ubuntu
ssh -i /path/to/your-key.pem ubuntu@<public-ip>

Once connected, read the per instance runner identity and the registration instructions written for this instance:

sudo cat /root/github-actions-runner-credentials.txt

That file is written on first boot with mode 0600 root:root. It contains no secret, because this image has no secret to hold: it records this instance's generated runner name, the work directory, the service account, the runner version and the reachable address, and it records runner.registered=false.

You can confirm the identity was written, and that the file is readable only by root, without printing the file:

echo "identity keys written by first boot:"
sudo grep -E '^runner\.(name|work_dir|service_user|version|registered)=' /root/github-actions-runner-credentials.txt
PERMS=$(sudo stat -c '%a %U:%G' /root/github-actions-runner-credentials.txt)
echo "permissions: ${PERMS}"
if [ "${PERMS}" = "600 root:root" ]; then
  echo "OK: instructions file is root only"
else
  echo "UNEXPECTED permissions: ${PERMS}"
  exit 1
fi

What is pre installed

Every package below is present because a real workflow needs it, and every one is listed here because a build image that quietly accumulates packages is a liability rather than a convenience.

Component Why it is here
actions/runner 2.335.1 The official self hosted runner agent, installed at /opt/actions-runner from the pinned, checksum verified upstream release
git Every workflow starts with actions/checkout
docker.io Container jobs and service containers, the single most common self hosted runner requirement. The ghrunner account is in the docker group
build-essential, make, pkg-config Compiled projects
python3, python3-pip, python3-venv Python tooling and virtual environments
jq JSON handling in workflow steps
rsync, zip, unzip, tar, gzip Artefact packing and transfer
curl, wget, ca-certificates, gnupg, openssl Fetching and verifying dependencies
iproute2 Network diagnostics

Docker is installed from the Ubuntu archive rather than from Docker's own repository, so it stays inside the distribution's security update stream that this image is patched against and that the AWS Marketplace CVE scan measures.

Pre installed toolchain and MIT licence

Verifying the image

These checks are read only and safe to run at any time.

Confirm the runner agent is installed and reports its real version:

sudo -u ghrunner /opt/actions-runner/bin/Runner.Listener --version

Confirm the first boot unit ran and Docker is up:

FB=$(systemctl is-enabled github-actions-runner-firstboot.service 2>/dev/null || true)
DK=$(systemctl is-active docker.service 2>/dev/null || true)
echo "firstboot unit: ${FB}"
echo "docker.service: ${DK}"
if [ "${FB}" = "enabled" ] && [ "${DK}" = "active" ]; then
  echo "OK: first boot initialisation is enabled and Docker is running"
else
  echo "UNEXPECTED: firstboot=${FB} docker=${DK}"
  exit 1
fi

Confirm the runner service ships installed but disabled, which is the correct state before you register:

STATE=$(systemctl is-enabled github-actions-runner.service 2>/dev/null || true)
ACTIVE=$(systemctl is-active github-actions-runner.service 2>/dev/null || true)
echo "github-actions-runner.service is-enabled: ${STATE}"
echo "github-actions-runner.service is-active:  ${ACTIVE}"
if [ "${STATE}" = "disabled" ] && [ "${ACTIVE}" != "active" ]; then
  echo "OK: the runner is installed and waiting for you to register it"
else
  echo "UNEXPECTED state: enabled=${STATE} active=${ACTIVE}"
  exit 1
fi

Ships unregistered: runner service installed but disabled

The built in self test

The image ships a self test that proves, on your own instance, everything that can honestly be proven about a runner that is deliberately not yet registered: that the agent executes and reports its real version, that every native dependency resolves, that the service is installed and correctly disabled, that the runner refuses to start while unregistered, and that no credential artefact of any kind is present. It includes a negative control, so a sweep that silently matched nothing cannot pass by accident.

sudo -u ghrunner /opt/actions-runner/bin/Runner.Listener --version >/dev/null 2>&1 || true
sudo bash /opt/actions-runner/runner-selftest.sh

The first run on a freshly launched instance takes a couple of minutes. That is expected and is not a fault. An EBS volume created from a snapshot loads its blocks from Amazon S3 on first access, so the first time the runner's .NET libraries are read they are being faulted in from S3 rather than from local storage. Subsequent runs complete in a few seconds. The command above touches the runner binary first so that the wait happens somewhere obvious rather than in the middle of the self test.

Built in self test

Run this after registering too. It will then correctly report the runner as configured, and the credential sweep becomes a check that your credentials are where they belong and nowhere else.

Registering your runner

Registration is the one step you perform yourself, and it takes about a minute.

The commands in this section are shown for you to run on your instance with your token. They are intentionally not executable checks, because a registration token is specific to your account and expires within the hour.

Step 1 — generate a registration token

In GitHub, go to the repository, organisation or enterprise you want the runner to serve:

  • Repository runner: repository → SettingsActionsRunnersNew self hosted runner
  • Organisation runner: organisation → SettingsActionsRunnersNew self hosted runner

Choose Linux and x64. GitHub shows a configuration command containing a registration token. You need only the URL and the token from it; the runner software is already installed on this image, so skip GitHub's download steps entirely.

The registration token is short lived and expires within about an hour. Generate it when you are ready to use it.

Step 2 — register

Read your instance's generated runner name from the instructions file first:

sudo grep '^runner.name=' /root/github-actions-runner-credentials.txt

Then run the configuration command as the ghrunner service account, substituting your URL, your token and that runner name:

sudo -u ghrunner /opt/actions-runner/config.sh \
     --url https://github.com/YOUR-ORG/YOUR-REPO \
     --token YOUR-REGISTRATION-TOKEN \
     --name YOUR-RUNNER-NAME \
     --work /opt/actions-runner/_work \
     --labels self-hosted,linux,x64,docker \
     --unattended

For an organisation wide runner, use the organisation URL instead:

--url https://github.com/YOUR-ORG

config.sh exchanges your short lived registration token for long lived runner credentials and writes them into /opt/actions-runner. Those files are the runner's identity: treat them as secrets, and never bake them into an image of your own.

Step 3 — start the runner

sudo systemctl enable --now github-actions-runner.service
sudo systemctl status github-actions-runner.service

The unit's ConditionPathExists gate is satisfied as soon as registration has written the state file, so the service now starts normally. Your runner appears as Idle under Settings → Actions → Runners in GitHub within a few seconds, and starts accepting jobs.

Running your first workflow

Add a workflow to your repository at .github/workflows/selfhosted.yml:

name: Self hosted smoke test
on: [push, workflow_dispatch]
jobs:
  build:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - name: Show the runner environment
        run: |
          echo "Running on $(hostname)"
          git --version
          docker --version
          python3 --version

Push the file, or trigger it from the Actions tab. The job is picked up by your runner rather than by a GitHub hosted one.

To target a specific runner rather than any self hosted one, use the labels you set at registration:

    runs-on: [self-hosted, linux, x64, docker]

Runner groups, labels and fleets

Labels are how workflows select a runner. Set them at registration with --labels, or edit them later in the GitHub UI under the runner's settings. Give runners labels that describe real capabilities, for example docker, gpu or build-large, so that runs-on expresses a requirement rather than a hostname.

Runner groups are how organisations control which repositories may use which runners. Create groups under Settings → Actions → Runner groups, then pass --runnergroup YOUR-GROUP at registration.

To build a fleet, launch several instances from this image. Each generates its own unique runner name at first boot, so they can all register into the same organisation without colliding, and GitHub distributes queued jobs across whichever are idle.

Security model

A self hosted runner executes whatever code your workflows contain. Treat it accordingly.

  • The runner does not run as root. It runs as ghrunner, a dedicated system account with no password, no sudo rights and no sudoers entry. The agent itself refuses to run as root.
  • No inbound ports. The runner connects outbound to GitHub, so the security group needs only your own SSH access. Do not open other ports for the runner's benefit; it does not want them.
  • Do not use a self hosted runner on a public repository. This is GitHub's own guidance and it matters: anyone who can open a pull request against a public repository can propose workflow changes, and on a self hosted runner that means proposing code that runs on your instance and inside your network. Keep self hosted runners to private repositories, or gate workflow runs on approval.
  • Jobs share the instance. By default consecutive jobs run on the same machine with the same working directory tree. If your workflows handle secrets or untrusted code, isolate them: run job steps inside containers, or treat runners as disposable and replace the instance between sensitive jobs.
  • The ghrunner account is in the docker group. That is required for container jobs, and it is effectively root equivalent on the instance. It is the standard trade off for a runner that supports Docker, and it is another reason to keep the instance's network exposure minimal.
  • Your registration credentials live only on your instance. They are created by config.sh at registration and are not present in the image. If you create your own AMI from a registered runner, remove the registration first, or you will clone your CI identity onto every instance launched from it.

Upgrading the runner

GitHub releases runner updates regularly, and a runner that is too far behind will be refused by the service. When the runner is registered and running as a service, it updates itself in place by default, so ordinary version drift is handled for you.

To check the version currently installed:

sudo -u ghrunner /opt/actions-runner/bin/Runner.Listener --version

To pin or control updates, pass --disableupdate at registration and manage upgrades yourself.

Removing a runner

To retire a runner cleanly, remove its registration from GitHub before terminating the instance, so it does not linger as an offline runner in your settings. Generate a removal token the same way you generated the registration token, then:

sudo systemctl disable --now github-actions-runner.service
sudo -u ghrunner /opt/actions-runner/config.sh remove --token YOUR-REMOVAL-TOKEN

You can also remove an offline runner directly in the GitHub UI under Settings → Actions → Runners.

Backup and maintenance

There is very little state to back up. The runner keeps no database: its state is the registration files in /opt/actions-runner and the transient _work directory, which is safe to lose because every job re checks out what it needs.

What is worth attention:

  • Patch the instance. The image ships fully patched at build time; apply operating system updates on your own schedule thereafter.
  • Watch disk usage. _work accumulates checkouts and build artefacts, and Docker accumulates images and layers. If builds start failing for space, prune Docker and clear old job directories.
  • Monitor runner health from Settings → Actions → Runners in GitHub, which shows each runner as Idle, Active or Offline.

To see how much space the runner working area is using:

sudo du -sh /opt/actions-runner/_work 2>/dev/null || echo "work directory is empty"

Support

cloudimg provides 24/7 technical support for this image by email at support@cloudimg.co.uk and via live chat.

We can help with deployment and first boot, registering the runner to a repository, organisation or enterprise, runner groups and labels, scaling to a fleet, configuring the systemd service, container and service container jobs with Docker, workflow and caching design, hardening the execution environment for untrusted code, network and security group rules, monitoring runner health, and runner version upgrades.

Trademarks

GitHub and GitHub Actions are trademarks of GitHub, Inc. cloudimg is not affiliated with, endorsed by, or sponsored by GitHub, Inc. or Microsoft. The runner agent distributed in this image is the unmodified official release, published by GitHub under the MIT Licence; a copy of that licence ships at /opt/actions-runner/LICENSE. All other product and company names are trademarks or registered trademarks of their respective holders.