Developer Tools AWS

GitHub Actions Runner 2.338.2 on AWS User Guide

| Product: GitHub Actions Runner 2.338.2 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the GitHub Actions Runner 2.338.2 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg Hardened Apache Subversion (SVN) & CI/CD Tools listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

The GitHub Actions runner is GitHub's open source (MIT licence) agent that runs your GitHub Actions workflow jobs on a machine you control. It opens an outbound HTTPS connection to GitHub and waits for jobs, so it needs no inbound port: you get jobs that can reach your private network, run longer or more often than hosted minutes allow, and stay inside your own AWS account.

The cloudimg image adds:

  • Registered to nobody. The image carries no GitHub token, no runner credentials and no registration state (.runner, .credentials and .credentials_rsaparams do not exist). The github-actions-runner service is installed but disabled, and cannot start until you register it.
  • A registration command that keeps the token off the command line. sudo github-actions-runner-config register --url https://github.com/OWNER/REPO asks for the registration token with echo off, or reads it from standard input, and hands it to the runner through its masked input variable. It refuses --token, so the token never appears in the process list, your shell history or the system journal (Step 6).
  • A fail-closed instance metadata filter, as defence in depth. Host jobs run inside github-actions-runner.service, which denies 169.254.169.254 and fd00:ec2::254. Default bridged container jobs and service containers are blocked twice: by DOCKER-USER firewall rules (IPv4 and IPv6) and by a metadata deny on every container's systemd scope. The runner will not start if the rules are missing or the filter service is stopped or masked, a Docker restart puts the rules back before any container runs, and first boot refuses to finish if it cannot apply them (Step 5). This is not a sandbox. ghrunner is in the docker group, which is root-equivalent: any workflow job can use Docker to get root on the instance and reach the metadata service. Read Security notes before you register the runner.
  • An unprivileged service account. The runner runs as ghrunner, which has no password and no sudo rights. It is in the docker group so container jobs work; read Security notes, because that membership is root-equivalent on the instance.
  • Node 24 only. The end-of-life Node 20 runtime is removed from the runner, and actions written for Node 20 run on the bundled Node 24 (FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true). npm, npx and corepack, which the runner never uses, are removed from its Node trees.
  • Docker Engine from the Ubuntu archive. docker.io, containerd and runc come from Ubuntu 24.04's own repositories, so unattended security upgrades patch them.
  • A per-instance runner name generated on first boot, so many instances can join one organisation without colliding, and a self-test that re-checks all of the above at any time.
  • Fully patched Ubuntu 24.04 LTS at build time, unattended security upgrades on, IMDSv2 required, and SSH (22) as the only open port.

What is on the image: GitHub Actions Runner 2.338.2 (with its bundled .NET 8 runtime and Node 24.22.1), Docker Engine 29.1.3 (docker.io, with containerd 2.2.1 and runc 1.3.4), git, build-essential, make, pkg-config, Python 3.12, jq, curl, wget, unzip, zip, rsync, gnupg, openssl, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.

Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with outbound HTTPS (TCP 443) to github.com and GitHub's Actions endpoints (an internet gateway or a NAT gateway).
  • A security group that allows TCP 22 (SSH) from your own network only. The runner needs no inbound port.
  • Admin rights on the GitHub repository or organisation you will register the runner to, so you can create a registration token.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick GitHub Actions Runner 2.338.2 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large (or larger for heavy builds), your key pair, a security group as above, and a subnet. Under Advanced details leave IAM instance profile empty (or choose a least-privilege one), keep Metadata version at V2 only (token required) and set Metadata response hop limit to 1. Workflow checkouts, build output and Docker images live on the root volume; raise its size under Configure storage for large builds.

With the AWS CLI (replace the values in angle brackets; this sets a 50 GiB root volume and a metadata hop limit of 1, see Security notes):

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled,HttpPutResponseHopLimit=1 \
  --block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=50,VolumeType=gp3}' \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=github-runner}]'

Attach no IAM instance profile unless your jobs need one, and then a least-privilege one: any job can get root on the instance and use the instance profile's credentials (see Security notes).

Step 2: Connect to your instance

Version SSH user
GitHub Actions Runner 2.338.2 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the instance

First boot applies the container metadata filter first, then gives the runner its per-instance name and writes the details file. It takes a few seconds. Docker, the filter and first boot are active; the runner is not registered, so its service is installed but disabled and stopped. That is the correct state, not a fault:

systemctl is-active docker github-actions-runner-imds-filter github-actions-runner-firstboot
sudo github-actions-runner-config status
active
active
active
registered: no
service: inactive (disabled)
IMDS_FILTER_OK

Only SSH listens on the network. The 127.0.0.1 port is containerd's local stream server (its number changes per boot), :53 is Ubuntu's local DNS resolver, :323 is the time service on the loopback address and :68 is the DHCP client:

sudo ss -lntup | awk 'NR>1 {print $1, $5}' | sort -u
tcp 0.0.0.0:22
tcp 127.0.0.1:35035
tcp 127.0.0.53%lo:53
tcp 127.0.0.54:53
tcp [::]:22
udp 127.0.0.1:323
udp 127.0.0.53%lo:53
udp 127.0.0.54:53
udp 172.31.82.163%ens5:68
udp [::1]:323

Step 4: Read the runner details

sudo cat /root/github-actions-runner-credentials.txt
# GitHub Actions Runner 2.338.2 on Ubuntu 24.04 by cloudimg - generated on this VM's first boot.
# This runner ships registered to nobody: it holds no GitHub credential and runs no job until you register it.
# Register it with a token from your repository or organisation (Settings, Actions, Runners, New self-hosted runner):
#   sudo github-actions-runner-config register --url https://github.com/OWNER/REPO
# The command asks for the token with echo off (or reads it from stdin). Never put the token on the command line.
runner.name=<RUNNER_NAME>
runner.version=2.338.2
runner.work_dir=<RUNNER_WORK_DIR>
runner.service_user=<RUNNER_SERVICE_USER>
runner.service_unit=github-actions-runner.service
instance.public_ip=<public-ip>
instance.private_ip=<private-ip>

The file holds no secret, because there is none: the only credential in the runner's life is the one GitHub issues when you register it. runner.name is unique to this instance (cloudimg-<host name>-<6 hex characters>) and is the name the runner registers with unless you pass --name. The work directory is /opt/actions-runner/_work and the service account is ghrunner.

Step 5: Run the self-test

The self-test checks the runner runs as ghrunner and reports its version, its libraries resolve, Node 24 is present, the service carries the metadata deny and refuses to start without the filter, the filter is in place, and, while the runner is unregistered, that no credential exists anywhere in the runner tree (it plants a decoy first, so a search that finds nothing cannot pass by mistake):

sudo github-actions-runner-selftest
ok: Runner.Listener --version = 2.338.2 (as ghrunner)
ok: Runner.Listener configure --help
ok: native libraries resolve
ok: Node 24 runtime present
ok: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true
ok: runner start gated on the filter check
ok: runner Requires= github-actions-runner-imds-filter.service
ok: runner runs as ghrunner
ok: container metadata filter in place (IPv4 + IPv6)
ok: github-actions-runner-imds-filter.service active
ok: docker active
ok: github-actions-runner.service installed, not enabled
ok: an unregistered listener refuses to run (exit 1)
ok: systemctl start of the unregistered runner is a no-op
ok: no runner credential material on disk
RUNNER_SELFTEST_OK

The metadata filter is three pieces you can inspect. It keeps ordinary jobs away from the metadata service; it does not stop a job that sets out to bypass it (see Security notes). The host-job deny is on the runner's own service; the container rules are rule 1 of Docker's DOCKER-USER chain for IPv4 and IPv6; the scope deny applies to every container Docker starts (Docker uses the systemd cgroup driver, so each container gets its own scope):

systemctl show -p IPAddressDeny github-actions-runner
sudo iptables -S DOCKER-USER
sudo ip6tables -S DOCKER-USER
cat /etc/systemd/system/docker-.scope.d/50-cloudimg-imds-deny.conf
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
-N DOCKER-USER
-A DOCKER-USER -d 169.254.169.254/32 -j REJECT --reject-with icmp-admin-prohibited
-N DOCKER-USER
-A DOCKER-USER -d fd00:ec2::254/128 -j REJECT --reject-with icmp6-adm-prohibited
[Scope]
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128

A plain container sees the metadata address time out, while the internet stays reachable (this pulls the small Alpine image from Amazon ECR Public):

sudo docker run --rm public.ecr.aws/docker/library/alpine:3.22 wget -q -T 4 -O /dev/null http://169.254.169.254/latest/meta-data/; echo "exit $?"
sudo docker run --rm public.ecr.aws/docker/library/alpine:3.22 wget -q -T 4 -O /dev/null https://github.com; echo "exit $?"
sudo docker run --rm --network host public.ecr.aws/docker/library/alpine:3.22 wget -q -T 4 -O /dev/null http://169.254.169.254/latest/meta-data/; echo "exit $?"
wget: download timed out
exit 1
exit 0
wget: download timed out
exit 1

github-actions-runner-imds-filter.service re-applies the rules whenever Docker starts. The runner service Requires= it and runs github-actions-runner-imds-filter check before every start, so if the rules or the scope deny are missing, or someone stops or masks the filter service, the runner does not start. cloudimg tested each of these on the build, with metadata reachable in a control run where both layers were removed. Leave the filter in place, and treat it as defence in depth: a job in the docker group can still get root and reach the metadata service (cloudimg's build proves this too, so the warning in Security notes is measured, not hypothetical).

Step 6: Register the runner

This is the one step only you can do, because the token belongs to your account.

  1. In GitHub open your repository (or organisation), then Settings > Actions > Runners > New self-hosted runner, and choose Linux / x64. Copy the token from the config.sh line on that page (only the token: you do not run GitHub's download and configure commands, the image has done that). A registration token expires after one hour.
  2. On the instance, register and start the runner, and paste the token when asked (nothing is shown as you paste):
sudo github-actions-runner-config register --url https://github.com/OWNER/REPO

For an organisation runner use --url https://github.com/OWNER. The command checks the metadata filter, registers as the per-instance name (or --name), and on success prints Registered as '<name>' and started github-actions-runner.service. The runner then shows as Idle under Settings > Actions > Runners.

Options: --labels gpu,build adds custom labels (the runner always has self-hosted, Linux and X64), --runnergroup <group> places an organisation runner in a runner group, --ephemeral makes the runner take one job and then unregister, and --no-start registers without starting the service.

Never put the token on a command line. The command refuses --token. To register without a terminal (from automation), send the token on standard input instead. For example, from your workstation with the GitHub CLI signed in to an account that administers the repository:

gh api -X POST repos/OWNER/REPO/actions/runners/registration-token --jq .token \
  | ssh -i <your-key.pem> ubuntu@<public-ip> sudo github-actions-runner-config register --url https://github.com/OWNER/REPO

The token travels through the pipe, not through any argument list, and the runner treats it as a secret, so it is not written to the journal or to the runner's own logs.

Step 7: Send a job to your runner

Target the runner with runs-on:

name: build-on-self-hosted
on: [push, workflow_dispatch]
jobs:
  build:
    runs-on: [self-hosted, linux, x64]
    steps:
      - uses: actions/checkout@v4
      - run: |
          hostname
          git --version
          docker --version

Container jobs (container:) and service containers (services:) run on the instance's Docker Engine. Follow the runner's log on the instance with sudo journalctl -u github-actions-runner -f.

The toolchain on the image:

git --version
docker --version
python3 --version
jq --version
gcc --version | sed -n 1p
make --version | sed -n 1p
git version 2.43.0
Docker version 29.1.3, build 29.1.3-0ubuntu3~24.04.2
Python 3.12.3
jq-1.7
gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
GNU Make 4.3

Install other languages in your workflow (for example with actions/setup-node, actions/setup-python or actions/setup-java), so each job gets the version it asks for.

Step 8: Updates and Node.js

The runner updates itself. Once registered, GitHub tells the runner when a new version is out, and the runner downloads and installs it in place (this is how GitHub's runner is designed, and a runner too far behind is refused by GitHub). The image does not turn this off. /usr/share/doc/github-actions-runner-cloudimg/VERSION records the version the image shipped; /opt/actions-runner/bin/Runner.Listener --version (run as ghrunner) shows the one now installed. The runner's .NET runtime is part of the runner and moves forward with these updates.

Node.js. The runner keeps its own Node.js runtimes for JavaScript actions under /opt/actions-runner/externals. This image ships Node 24 only, and the service sets FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true, so actions that still declare Node 20 run on Node 24:

ls /opt/actions-runner/externals
systemctl show -p Environment github-actions-runner
node24
node24_alpine
Environment=FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true RUNNER_MANUALLY_TRAP_SIG=1 HOME=/home/ghrunner

A runner self-update may lay down GitHub's full layout again, Node 20 included; the service setting still sends actions to Node 24.

Ubuntu and Docker. Unattended upgrades install Ubuntu security updates, including Docker Engine, containerd and runc, every day. Reboot when /var/run/reboot-required exists; the filter is re-applied on every boot and every Docker start.

Step 9: Remove the runner

Create a removal token on the same GitHub page (Settings > Actions > Runners, select the runner, Remove), then on the instance (paste the token when asked):

sudo github-actions-runner-config remove

This stops and disables the service, removes the runner from GitHub and deletes its local registration. You can then register again, to the same or another repository.

Security notes

  • Register the runner only to repositories and organisations whose workflow authors you trust. A self-hosted runner runs whatever the workflow says. Never use it for a public repository or for pull requests from forks, where anyone can open a pull request that runs on your instance; GitHub gives the same advice in its self-hosted runner security guidance. Prefer --ephemeral runners on fresh instances for stronger separation between jobs.
  • The docker group is root-equivalent, so any job can reach the instance metadata service. ghrunner is in the docker group because container jobs need the Docker socket. Any workflow job can therefore start a privileged container or mount the host's file system (docker run -v /:/host), which makes it root on this instance, and from there it can reach the instance metadata service and remove the filter. cloudimg's build reproduces this: a job running docker run --privileged -v /:/host reaches the metadata service. The metadata filter blocks the metadata service only for default bridged container jobs, service containers and host jobs that do not use Docker; it is defence in depth, not a sandbox. Treat every repository that can send jobs to this runner as having root on the instance.
  • Attach no IAM instance profile, or a least-privilege one. Any job can get root and use the instance profile's credentials, so give the instance no more AWS permissions, network reach or data than every repository that can send it jobs should have.
  • Require IMDSv2 with a metadata hop limit of 1. The image requires IMDSv2. Launching with HttpPutResponseHopLimit=1 (Step 1) also stops IMDSv2 token responses from reaching any bridged container at the network level, in case the filter is removed. Like the filter it is defence in depth: it does not stop a job that has root on the host.
  • Keep SSH to your own network and keep no inbound port open for the runner: it only dials out.
  • The registration token and the runner credentials. Registration writes .runner, .credentials and .credentials_rsaparams under /opt/actions-runner, owned by ghrunner and readable only by it. Anyone with those files can act as this runner. Do not copy them, and do not create an AMI of a registered instance (remove the runner first).

Build provenance and licence

The runner is the official actions-runner-linux-x64-2.338.2.tar.gz from GitHub's v2.338.2 release (tag commit d1eb12d4b1), verified against the SHA-256 published in the release notes (baebf204307bac6193480852732f0aba2ae97b6c5597c244f4d5558390147c40). It is MIT-licensed; the licence, the shipped version, the runtime package list and a note of every change to the release are in /usr/share/doc/github-actions-runner-cloudimg/:

cat /usr/share/doc/github-actions-runner-cloudimg/README.cloudimg
GitHub Actions Runner 2.338.2 on Ubuntu 24.04 by cloudimg

Upstream: https://github.com/actions/runner (MIT, LICENSE in this directory), release v2.338.2,
tag commit d1eb12d4b1, tarball actions-runner-linux-x64-2.338.2.tar.gz sha256 baebf204307bac6193480852732f0aba2ae97b6c5597c244f4d5558390147c40.
Installed in /opt/actions-runner, owned by the service account ghrunner.
Changes to the upstream tarball: externals/node20 and externals/node20_alpine removed (Node 20 is end of life);
npm, npx and corepack removed from externals/node24 and externals/node24_alpine (the runner never runs them).
The runner bundles Microsoft's self-contained .NET 8 runtime (MIT) and System.Net.Http.Formatting.dll
(Microsoft .NET Library License, redistributed unmodified as part of the runner, as GitHub ships it).
GitHub and GitHub Actions are trademarks of GitHub, Inc. cloudimg is not affiliated with or endorsed by GitHub.

Troubleshooting

  • systemctl start github-actions-runner does nothing. The runner is not registered (Step 6): the service only starts once /opt/actions-runner/.runner exists and first boot has finished.
  • The runner does not start after registration, and its status mentions the filter. Run sudo github-actions-runner-imds-filter check. It names what is missing; sudo systemctl restart docker re-applies the rules. If the filter service is masked, unmask it with sudo systemctl unmask github-actions-runner-imds-filter.
  • register says the token does not look right, or GitHub refuses it. Registration tokens expire after an hour; create a new one. Paste only the token, not the whole config.sh line.
  • The runner shows Offline in GitHub. Check sudo journalctl -u github-actions-runner -n 50 and that the instance has outbound HTTPS to github.com.
  • First boot failed. systemctl status github-actions-runner-firstboot and sudo journalctl -u github-actions-runner-firstboot. First boot stops before naming the runner if it cannot apply the metadata filter, so the runner cannot start in that state.
  • Re-check the image at any time with sudo github-actions-runner-selftest.

Support

cloudimg provides 24/7 technical support for this image: registration, labels and runner groups, scaling out, container and service container jobs, the metadata filter, network rules and runner upgrades. Critical issues receive a one hour average response time. Contact support@cloudimg.co.uk.

GitHub and GitHub Actions are trademarks of GitHub, Inc. cloudimg is not affiliated with or endorsed by GitHub, Inc.; the names are used only to identify the software this image runs and the service it connects to.