Gr
Developer Tools AWS

GitLab Runner 19.5.3 on AWS User Guide

| Product: GitLab Runner 19.5.3 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the GitLab Runner 19.5.3 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.

GitLab CI/CD splits into two halves. Your GitLab instance stores the code, the pipeline definitions and the run history. A runner is the other half: it claims a queued job, runs it, streams the log back and cleans up afterwards. Without a runner, pipelines queue and nothing happens. GitLab Runner is the official runner, published by GitLab. It runs each job in its own container on the instance and connects outward to your GitLab, so nothing has to be opened inbound.

The cloudimg image adds:

  • GitLab Runner 19.5.3 from GitLab's own apt repository, pinned by version and checksum, with that repository's signing key trusted for that repository alone.
  • Docker from the Ubuntu archive, so the container engine gets Ubuntu security updates through unattended upgrades along with the rest of the system.
  • The runner's own package source added to unattended upgrades, inside the 19.x line, so the runner is patched too rather than being the one component that quietly goes stale.
  • alpine:3.22 and the runner's helper image already present, both with every package upgraded, so a first pipeline job runs without reaching a registry.
  • Connected to nobody. The image holds no runner registration, no authentication token, no runner system id and no remembered instance address, and the runner will not start until you connect it. A runner image that carried a registration would give every instance the same identity, claiming jobs from (and sending build logs to) somebody else's GitLab.
  • A unique runner name made on your instance's first boot, so two runners on one GitLab never collide.
  • Jobs run in containers only, without the Docker socket or the host's filesystem. Registration writes a docker executor with privileged = false and a Docker-managed /cache volume as its only volume, and refuses the shell executor, which would run job scripts on the instance itself. cloudimg's build runs real jobs that ask for the socket, privilege, the host network and a Docker-in-Docker service, and checks every container they start.
  • The gitlab-runner account is in the docker group, which is root-equivalent on the host. The runner needs that to start job containers. Whoever controls the runner service, or finds a container escape in Docker or the kernel, has root on the instance, so treat the instance as belonging to the people who write the pipelines it runs (Step 9).
  • A firewall rule refuses job containers the instance metadata service, over IPv4 and IPv6, and systemd denies it to the runner's own services. This is defence in depth, not a sandbox: launch with IMDSv2 and a hop limit of 1 and with no instance profile, or a least-privilege one, as well (Step 1).
  • SSH is the only port open to the network. The runner's metrics and session-server listeners are not enabled.
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.

Trademark notice. GitLab is a trademark of GitLab Inc. "GitLab" and "GitLab Runner" are used here only to describe the software this image runs, which is the unmodified open source release under the MIT licence. cloudimg is not affiliated with, endorsed by, or sponsored by GitLab Inc.

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to your GitLab instance and to the container registries your pipelines use.
  • A GitLab instance (self-managed 16.0 or later, or GitLab.com), and permission there to create a runner: instance administrator, group owner or project maintainer.
  • A security group that allows TCP 22 (SSH) from your own networks only and nothing else. A runner needs no inbound port: it dials out to your GitLab.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick GitLab Runner 19.5.3 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large (2 vCPU, 8 GiB), or larger if your builds are heavy, your key pair and the security group above.

Under Advanced details, set Metadata version to V2 only (token required) and Metadata response hop limit to 1. IMDSv2 with a hop limit of 1 means a metadata token can only be used on the instance itself and never from inside a container, which backs up the firewall rule shown in Step 9.

Leave IAM instance profile empty, or choose a least-privilege role that grants only what your pipelines need. The firewall rule is defence in depth: any process on the instance itself, including one running as root through the docker group, can still read a role's credentials from the metadata service.

From the AWS CLI, the same launch is:

aws ec2 run-instances \
  --image-id <ami-id> \
  --instance-type m5.large \
  --key-name <key-name> \
  --subnet-id <subnet-id> \
  --security-group-ids <security-group-id> \
  --metadata-options HttpEndpoint=enabled,HttpTokens=required,HttpPutResponseHopLimit=1 \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=gitlab-runner-01}]'

For an instance you have already launched, set the hop limit with:

aws ec2 modify-instance-metadata-options --instance-id <instance-id> \
  --http-tokens required --http-put-response-hop-limit 1 --http-endpoint enabled

Step 2: Connect to your instance

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

Step 3: Confirm the runner is installed and idle

The runner is installed, enabled and deliberately not running, because it has not been told where to connect yet.

gitlab-runner --version | sed -n '1,2p'
sudo docker version --format '{{.Server.Version}}'
systemctl is-active docker gitlab-runner-egress-filter
systemctl is-enabled gitlab-runner.service
systemctl is-active gitlab-runner.service || echo "(inactive is the correct state until the runner is connected)"

gitlab-runner.service reporting inactive while is-enabled reports enabled is the correct state, not a fault. The service carries a condition that systemd checks before every start, and while no runner is configured it skips the service cleanly:

sudo /usr/local/sbin/gitlab-runner-registered-check.sh --explain || echo "(exit 1 means: not connected yet)"

Two container images are present, so a pipeline job can run straight away:

sudo docker image ls

Step 4: Check what the network can reach

sudo ss -Hltn | awk '{print $4}' | sort -u

Off the instance, only SSH is listening; anything else is bound to the loopback interface, and that stays true after you connect the runner. The bundled checker asserts that set by equality rather than by looking for a few unwanted ports:

sudo /usr/local/sbin/gitlab-runner-port-check.sh

And this confirms the image is connected to nothing: no runner section, no authentication token, no runner system id and no remembered instance address.

sudo /usr/local/sbin/gitlab-runner-registration-assert.sh

Step 5: Your instance's runner identity

Every instance from this image makes its own runner name on first boot. That name is what tells two of these runners apart in your GitLab runner list.

sudo grep -vE '^#' /root/gitlab-runner-credentials.txt

That file is 0600 root:root and holds no secret: this appliance has no credential of its own until you connect it.

NAME="$(sudo grep '^GITLAB_RUNNER_NAME=' /root/gitlab-runner-credentials.txt | cut -d= -f2-)"
echo "This instance will register as: ${NAME}"
echo "Recorded for this instance: <GITLAB_RUNNER_NAME> at <GITLAB_RUNNER_HOST>"

The runner's system id, which GitLab uses to tell machines apart, is not in the image either: the runner creates it the first time it connects, so every instance has its own.

Step 6: Create a runner in GitLab and copy its token

Since GitLab 16.0 you create the runner in GitLab first, and GitLab gives you a runner authentication token that begins glrt-. The older registration token was removed in GitLab 18.0 and is not used by this image.

Scope Where to create it
Every project on the instance Admin area > CI/CD > Runners > New instance runner
One group Group > Settings > CI/CD > Runners > New group runner
One project Project > Settings > CI/CD > Runners > New project runner

While creating it, set the tags you want this runner to serve (or tick Run untagged jobs) and give it a description. Those belong to the runner object in GitLab, not to this instance, so you change them in GitLab later without touching the runner. A project or group runner is a smaller audience than an instance runner; see Step 9 before you choose.

GitLab shows the token once. Copy it, and note the root URL of your instance, such as https://gitlab.example.com/ or https://gitlab.com/, not the URL of a project. Do not put a user name or password in the URL: the address must be the instance root alone.

Step 7: Connect the runner to your instance

Put the two values in /etc/gitlab-runner-registration.env and start the registration service:

sudo tee /etc/gitlab-runner-registration.env >/dev/null <<'EOF'
GITLAB_INSTANCE_URL=https://gitlab.example.com/
GITLAB_RUNNER_TOKEN=<your-token>
EOF
sudo chmod 0600 /etc/gitlab-runner-registration.env
sudo systemctl start gitlab-runner-register

The token reaches GitLab Runner through an environment variable rather than a command line, because a command line is readable by every account on the machine for as long as the process lives. Registration runs as the runner's own account, writes the runner into /etc/gitlab-runner/config.toml, removes the token from /etc/gitlab-runner-registration.env, checks that the job settings it produced are the ones this image permits, and starts the runner. Its log names only the scheme, host and port of your instance, and if registration fails the runner's own messages are printed with any token masked.

Confirm it connected:

systemctl is-active gitlab-runner.service || echo "(not connected yet: complete the block above first)"
sudo journalctl -u gitlab-runner.service -n 20 --no-pager

A line reading Starting multi-runner followed by Checking for jobs means the runner is polling your instance. It should now show as online under Runners in GitLab, with the name from Step 5.

You can also put the same two lines in the instance's user data at launch to have the runner connect on its first boot. User data is readable through the instance metadata service and by anyone allowed to describe the instance in your AWS account, so if that matters to you, leave it out at launch and write the token in afterwards instead.

Step 8: Run your first pipeline job

In any project the runner serves, commit .gitlab-ci.yml:

hello:
  image: alpine:3.22
  script:
    - echo "Hello from $(hostname)"
    - sed -n '1,2p' /etc/os-release

Push it. The pipeline appears under the project's Build > Pipelines, and the runner claims the job within a few seconds.

image: alpine:3.22 names an image that already ships on this instance, so this first job needs no registry access. Any other image works too: it is pulled on first use and then cached. If your runner was created with tags, add a matching tags: list to the job, or GitLab leaves it queued.

Step 9: Check what a job can and cannot reach

A runner executes code from anyone who can push to the projects it serves, so it is worth knowing exactly what a job can and cannot do. This runs a real pipeline job, through the real runner, against a temporary server on the loopback interface that speaks GitLab's runner protocol, so it works before you have connected anything, and it reads the job's own output back the way your GitLab would:

sudo /usr/local/sbin/gitlab-runner-job-probe.sh

It checks, by running them rather than by reading configuration, that the job produced its own output; that the job container had no Docker socket, so a job cannot create containers of its own or take over the host; that the job's requests to the instance metadata service were refused, both a plain request and a request for an IMDSv2 token, over IPv4 and IPv6; and that the job used the image already on the instance. That refusal is defence in depth, not a sandbox: it closes one path to the instance's IAM role, and the IMDSv2 hop limit of 1 from Step 1 closes it again. The probe uses its own temporary configuration and never touches a registration you have made, so it is safe to run on a connected runner. It runs as root only.

The metadata refusal is a firewall rule on container traffic only, for IPv4 and IPv6, reapplied whenever Docker restarts and at every boot:

sudo iptables -S DOCKER-USER | grep REJECT
sudo ip6tables -S DOCKER-USER | grep REJECT
sudo /usr/local/sbin/gitlab-runner-egress-filter.sh check

The runner depends on this rule: gitlab-runner.service requires the filter unit and checks the rules before it starts, and Docker re-applies them every time it starts. If the rules are missing, the runner refuses to start rather than run jobs without them:

systemctl show -p Requires -p ExecStartPre gitlab-runner.service | grep -o 'gitlab-runner-egress-filter[^ ;]*' | sort -u

The instance itself still reaches the metadata service normally, because the rules apply to forwarded container traffic only. The runner's own services are blocked from it by systemd as well:

systemctl show -p IPAddressDeny gitlab-runner gitlab-runner-register docker containerd

For the whole posture in one command:

sudo /usr/local/sbin/gitlab-runner-verify.sh

Who to trust with this runner. The gitlab-runner account is in the docker group, which is root-equivalent on the host. Job containers cannot reach the Docker socket or the host's filesystem, because registration writes a non-privileged docker executor with no host volumes and refuses anything wider, and cloudimg's build proves this with real jobs that ask for more; the metadata firewall rule is defence in depth on top. None of this makes it safe to run pipelines from people you do not trust: a container or kernel escape would give a job the instance. So:

  • launch with IMDSv2 required and HttpPutResponseHopLimit=1 (Step 1);
  • attach no instance profile, or a least-privilege one;
  • connect the runner only to GitLab instances, and serve only groups and projects, whose pipeline authors you trust. A project or group runner is a smaller audience than an instance runner, and never let this runner pick up merge request pipelines from forks of a public project.

You can see the group membership for yourself:

id gitlab-runner

Step 10: What jobs are allowed to do

The job settings are written into the runner's configuration when you connect it, and you can read them back at any time:

sudo /usr/local/sbin/gitlab-runner-posture-check.sh

It refuses four things, each for a reason:

  • A non-docker executor. The shell executor would run job scripts directly on this instance, as the runner's own account, outside every protection above. Registration refuses it.
  • Privileged containers. A privileged job container is effectively the host.
  • Host bind mounts. Any volume entry that maps a host path into the job, the Docker socket being only the best known, is refused. Jobs get a Docker-managed /cache volume and nothing from the host filesystem.
  • The host network, extra kernel capabilities and host devices.

If you edit /etc/gitlab-runner/config.toml by hand, run the check again before you restart the runner.

Jobs run one at a time (concurrent = 1), which suits m5.large. Raise it on a larger instance if you want parallel jobs:

sudo grep -E '^(concurrent|check_interval)' /etc/gitlab-runner/config.toml

Step 11: Keeping it patched

Ubuntu and Docker update through Ubuntu's unattended upgrades. GitLab Runner has no package in the Ubuntu archive, so this image adds GitLab's repository to the allowed origins; without that the runner would be the one component that never got patched:

grep -A2 'Allowed-Origins' /etc/apt/apt.conf.d/52cloudimg-gitlab-runner-origin
apt-cache policy gitlab-runner | sed -n '1,3p'

The package is pinned inside the 19.x line, because a runner has to be compatible with the GitLab instance it serves and a major-version jump is worth making deliberately:

cat /etc/apt/preferences.d/gitlab-runner

To move to a new major version when your GitLab is ready, edit that pin, then run sudo apt-get update && sudo apt-get install --only-upgrade gitlab-runner gitlab-runner-helper-images.

Step 12: Move the runner to another instance

The runner refuses to re-register somewhere new on its own, because that would abandon the runner your first GitLab still lists while this instance quietly stopped claiming its jobs. To move it deliberately, disconnect it on the instance:

sudo /usr/local/sbin/gitlab-runner-disconnect.sh

That stops the runner, asks your GitLab to delete the runner object and removes the local registration. Then repeat Step 7 with the new instance URL and a token created there.

Use this rather than running gitlab-runner unregister yourself. The runner here runs as an unprivileged account, and unregister run under sudo rewrites /etc/gitlab-runner/config.toml as root, which leaves the service unable to read its own configuration. The command above does the same work and keeps the ownership right.

Troubleshooting

gitlab-runner.service is inactive and systemctl start does nothing. That is the shipped state before you connect it. Check what the condition sees:

sudo /usr/local/sbin/gitlab-runner-registered-check.sh --explain || echo "(exit 1 means: not connected yet)"

If no runner is configured, go back to Step 7.

Registration was rejected. The usual causes are a token that is not a runner authentication token (it must begin glrt-), a token that has been revoked, a runner deleted in GitLab, or an instance URL that points at a project rather than the instance root. The service says which:

sudo journalctl -u gitlab-runner-register.service -n 30 --no-pager

Jobs stay queued. If the runner was created with tags, the job needs a matching tags: list, or the runner needs Run untagged jobs switched on; both are settings on the runner in GitLab. Check the runner is online on your Runners page and that this instance is polling:

sudo journalctl -u gitlab-runner.service --no-pager -n 50 | grep -m1 'Checking for jobs' || echo "runner is not polling yet"

A job cannot reach something on the network. The only destination the firewall refuses to job containers is the instance metadata service, a defence-in-depth layer (Step 9). DNS and ordinary internet access are unaffected:

sudo docker run --rm alpine:3.22 sh -c 'nslookup gitlab.com >/dev/null 2>&1 && echo "DNS works from a job container"'

Check the whole appliance at once.

sudo /usr/local/sbin/gitlab-runner-verify.sh

Support and licensing

cloudimg provides 24/7 technical support for this image by email and live chat: support@cloudimg.co.uk or https://www.cloudimg.co.uk/support. We help with launching, connecting the runner to GitLab or GitLab.com, pipelines and container images, caching, running several runners and upgrades. Include the instance type, the Region and the output of sudo /usr/local/sbin/gitlab-runner-verify.sh.

GitLab Runner is released under the MIT licence. Docker and the other system packages are Ubuntu archive packages under their own licences (see /usr/share/doc/<package>/copyright). cloudimg is not affiliated with, endorsed by, or sponsored by GitLab Inc.