GitHub Actions Runner 2.338.2
Overview
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. Your jobs can reach your private network, run longer or more often than hosted minutes allow, and stay inside your own AWS account.
This guide covers launching and using the GitHub Actions Runner 2.338.2 on Ubuntu 24.04 AMI from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud Hardened Apache Subversion (SVN) & CI/CD Tools listing on AWS Marketplace. You select it as the software version when you subscribe and launch.
What you get:
- Registered to nobody. The image carries no GitHub token, no runner credentials and no registration state (
.runner,.credentialsand.credentials_rsaparamsdo not exist). Thegithub-actions-runnerservice 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/REPOasks 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: byDOCKER-USERfirewall 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.ghrunneris in thedockergroup, 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. The account is in thedockergroup so that 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,containerdandrunccome 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.
- A fully patched Ubuntu 24.04 LTS base at build time, with unattended security upgrades on, IMDSv2 required, and SSH (22) as the only open port.
- 24/7 SecureAnyCloud support
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.comand 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 that you can create a registration token.
Launch the instance
In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Select GitHub Actions Runner 2.338.2 on Ubuntu 24.04 as the software version, choose your Region, and select Launch through EC2.
Choose m5.large (or larger for heavy builds), your key pair, a security group as described 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, so raise its size under Configure storage for large builds.
To launch with the AWS CLI instead, run the following command and replace the values in angle brackets. It 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 attach a least-privilege one. Any job can get root on the instance and use the instance profile's credentials (see Security notes).
Connect to your instance
Connect over SSH as the user shown below.
Version SSH user
GitHub Actions Runner 2.338.2 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>Check the instance
On first boot, the instance applies the container metadata filter, then gives the runner its per-instance name and writes the details file. This takes a few seconds. Docker, the filter and first boot are active. The runner is not registered yet, so its service is installed but disabled and stopped. This is the expected state, not a fault:
systemctl is-active docker github-actions-runner-imds-filter github-actions-runner-firstboot
sudo github-actions-runner-config statusactive active active registered: no service: inactive (disabled) IMDS_FILTER_OKOnly SSH listens on the network. The 127.0.0.1 port is containerd's local stream server (its number changes on each 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 -utcp 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]:323Read the runner details
sudo cat /root/github-actions-runner-credentials.txt# GitHub Actions Runner 2.338.2 on Ubuntu 24.04 by sac - 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. The only credential in the runner's life is the one GitHub issues when you register it. runner.name is unique to this instance (sac-<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.
Run the self-test
The self-test verifies that 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, and the filter is in place. While the runner is unregistered, it also verifies 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-selftestok: 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_OKThe metadata filter has three parts that you can inspect. It keeps ordinary jobs away from the metadata service, but 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-sac-imds-deny.confIPAddressDeny=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/128A plain container sees the metadata address time out, while the internet stays reachable. The following test 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 1github-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. If the rules or the scope deny are missing, or someone stops or masks the filter service, the runner does not start. SecureAnyCloud tested each of these cases 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, and SecureAnyCloud's build confirms this, so the warning in Security notes is measured, not hypothetical.
Register the runner
Only you can complete this step, because the registration token belongs to your GitHub account.
- In GitHub, open your repository (or organisation), then select Settings > Actions > Runners > New self-hosted runner, and choose Linux / x64. Copy only the token from the
config.shline on that page. Do not run GitHub's download and configure commands, because the image has already done that. A registration token expires after one hour. - 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/REPOFor an organisation runner, use --url https://github.com/OWNER. The command checks the metadata filter, registers under 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.
The command accepts these 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. --no-start registers the runner without starting the service.
Never put the token on a command line. The command refuses --token. To register without a terminal, for example from automation, send the token on standard input instead. The following example runs 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/REPOThe token travels through the pipe, not through any argument list. The runner treats it as a secret, so it is not written to the journal or to the runner's own logs.
Send a job to your runner
Target your runner from a workflow 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 --versionContainer jobs (container:) and service containers (services:) run on the instance's Docker Engine. To follow the runner's log on the instance, run sudo journalctl -u github-actions-runner -f.
The image includes the following toolchain:
git --version docker --version python3 --version jq --version gcc --version | sed -n 1p make --version | sed -n 1pgit 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.3Install other languages in your workflow, for example with actions/setup-node, actions/setup-python or actions/setup-java, so that each job gets the version it asks for.
Updates and Node.js
The runner updates itself. Once registered, the runner is told by GitHub when a new version is available, and it downloads and installs the update in place. This is how GitHub's runner is designed, and GitHub refuses a runner that is too far behind. The image does not turn this off. /usr/share/doc/github-actions-runner-sac/VERSION records the version the image shipped, and /opt/actions-runner/bin/Runner.Listener --version (run as ghrunner) shows the version 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-runnernode24 node24_alpine Environment=FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true RUNNER_MANUALLY_TRAP_SIG=1 HOME=/home/ghrunnerA runner self-update may restore GitHub's full layout, Node 20 included. The service setting still sends actions to Node 24.
Ubuntu and Docker. Unattended upgrades install Ubuntu security updates every day, including Docker Engine, containerd and runc. Reboot when /var/run/reboot-required exists. The filter is re-applied on every boot and every Docker start.
Remove the runner
Create a removal token on the same GitHub page: select Settings > Actions > Runners, select the runner, then select Remove. On the instance, run the following command and paste the token when asked:
sudo github-actions-runner-config removeThis command stops and disables the service, removes the runner from GitHub and deletes its local registration. You can then register the runner 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
--ephemeralrunners on fresh instances for stronger separation between jobs. - The docker group is root-equivalent, so any job can reach the instance metadata service. The ghrunner account 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. From there it can reach the instance metadata service and remove the filter. SecureAnyCloud'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, this is defence in depth and 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, because it only dials out.
- Protect the registration token and the runner credentials. Registration writes
.runner,.credentialsand.credentials_rsaparamsunder/opt/actions-runner, owned byghrunnerand readable only by that account. 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-sac/:
cat /usr/share/doc/github-actions-runner-sac/README.sacGitHub Actions Runner 2.338.2 on Ubuntu 24.04 by sac 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. sac is not affiliated with or endorsed by GitHub.Troubleshooting
systemctl start github-actions-runnerdoes nothing. The runner is not registered (Step 6). The service starts only once/opt/actions-runner/.runnerexists 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, which names what is missing. Runsudo systemctl restart dockerto re-apply the rules. If the filter service is masked, unmask it withsudo systemctl unmask github-actions-runner-imds-filter. registersays the token does not look right, or GitHub refuses it. Registration tokens expire after an hour, so create a new one. Paste only the token, not the wholeconfig.shline.- The runner shows Offline in GitHub. Check
sudo journalctl -u github-actions-runner -n 50, and confirm that the instance has outbound HTTPS togithub.com. - First boot failed. Check
systemctl status github-actions-runner-firstbootandsudo 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
SecureAnyCloud provides 24/7 technical support for this image, covering 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 sales@secureanycloud.com.
GitHub and GitHub Actions are trademarks of GitHub, Inc. SecureAnyCloud 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.
Deploy on AWS
Launch GitHub Actions Runner 2.338.2 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com