GitLab Runner 19.5.3
Overview
GitLab CI/CD has 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.
This guide covers launching the GitLab Runner 19.5.3 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. Select it as the software version when you subscribe and launch.
What you get:
- 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, within the 19.x line, so the runner is patched too instead of quietly going stale
alpine:3.22and 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
- Container-only jobs, 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. SecureAnyCloud'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-runneraccount is in thedockergroup, which is root-equivalent on the host. The runner needs this to start job containers. Whoever controls the runner service, or finds a container escape in Docker or the kernel, has root on the instance. Treat the instance as belonging to the people who write the pipelines it runs (Step 9). - A firewall rule that refuses job containers access to the instance metadata service, over IPv4 and IPv6, and systemd denies the service to the runner's own services. This is defence in depth, not a sandbox. Also launch with IMDSv2, a hop limit of 1, and either no instance profile or a least-privilege one (Step 1).
- SSH as the only open port. The runner's metrics and session-server listeners are not enabled.
- Fully patched base. Ubuntu 24.04 LTS is patched at build time, with unattended security upgrades switched on.
- 24/7 SecureAnyCloud support
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. SecureAnyCloud is not affiliated with, endorsed by, or sponsored by GitLab Inc.
Prerequisites
You need:
- 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, because it dials out to your GitLab.
Launch the instance
In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Select GitLab Runner 19.5.3 on Ubuntu 24.04 as the software version, choose your Region, then choose Launch through EC2.
Choose m5.large (2 vCPU, 8 GiB), or a larger size if your builds are heavy. Select your key pair and the security group described above.
Under Advanced details, set Metadata version to V2 only (token required) and Metadata response hop limit to 1. With IMDSv2 and a hop limit of 1, a metadata token can be used only on the instance itself and never from inside a container. This backs up the firewall rule described 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.
To launch from the AWS CLI instead, run:
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 enabledConnect to your instance
Connect over SSH with your key pair. For the GitLab Runner 19.5.3 on Ubuntu 24.04 AMI, the SSH user is ubuntu.
ssh -i <your-key.pem> ubuntu@<public-ip>Confirm the runner is installed and idle
The runner is installed and enabled, but deliberately not running, because it has not been told where to connect yet. Check its state:
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. While no runner is configured, systemd 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 lsCheck what the network can reach
List the listening ports:
sudo ss -Hltn | awk '{print $4}' | sort -uFrom the network, only SSH is listening. Everything else is bound to the loopback interface, and that stays true after you connect the runner. The bundled checker asserts that exact set of listeners, rather than looking for a few unwanted ports:
sudo /usr/local/sbin/gitlab-runner-port-check.shThis command confirms that 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.shFind your instance's runner identity
Every instance from this image generates its own runner name on first boot. The name tells two of these runners apart in your GitLab runner list.
sudo grep -vE '^#' /root/gitlab-runner-credentials.txtThat file is 0600 root:root and holds no secret, because 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.
Create a runner in GitLab and copy its token
Since GitLab 16.0, you create the runner in GitLab first. GitLab then 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.
Create the runner at the scope you need:
- 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 the runner, set the tags you want it to serve (or tick Run untagged jobs) and give it a description. These belong to the runner object in GitLab, not to this instance, so you can change them later in GitLab without touching the runner. A project or group runner serves a smaller audience than an instance runner, so read Step 9 before you choose.
GitLab shows the token only once. Copy it, and note the root URL of your instance, such as https://gitlab.example.com/ or https://gitlab.com/. Do not use the URL of a project, and do not put a user name or password in the URL. The address must be the instance root alone.
Connect the runner to your instance
Write the two values to /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-registerThe 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. It 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 ones this image permits, and starts the runner. The log names only the scheme, host and port of your instance. If registration fails, the runner's own messages are printed with any token masked.
Confirm that the runner 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-pagerA line reading Starting multi-runner followed by Checking for jobs means the runner is polling your instance. The runner 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, so the runner connects 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. If that matters to you, leave the token out at launch and write it in afterwards instead.
Run your first pipeline job
In any project the runner serves, commit a .gitlab-ci.yml file:
hello: image: alpine:3.22 script: - echo "Hello from $(hostname)" - sed -n '1,2p' /etc/os-releasePush the file. 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 you created the runner with tags, add a matching tags: list to the job, or GitLab leaves the job queued.
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. The command below runs a real pipeline job through the real runner. It talks to a temporary server on the loopback interface that speaks GitLab's runner protocol, so it works before you have connected anything. It reads the job's own output back the way your GitLab would:
sudo /usr/local/sbin/gitlab-runner-job-probe.shThe check runs the jobs rather than reading configuration. It confirms four things:
- The job produced its own output.
- The job container had no Docker socket, so a job cannot create containers of its own or take over the host.
- 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.
- The job used the image already on the instance.
The metadata 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. It covers IPv4 and IPv6 and is 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 checkThe 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 -uThe instance itself still reaches the metadata service normally, because the rules apply to forwarded container traffic only. systemd also blocks the runner's own services from the metadata service:
systemctl show -p IPAddressDeny gitlab-runner gitlab-runner-register docker containerdTo check the whole security posture with one command, run:
sudo /usr/local/sbin/gitlab-runner-verify.shDecide whom 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. SecureAnyCloud's build proves this with real jobs that ask for more, and the metadata firewall rule adds defence in depth on top.
None of this makes it safe to run pipelines from people you do not trust, because a container or kernel escape would give a job the instance. Follow these rules:
- 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 serves a smaller audience than an instance runner. Never let this runner pick up merge request pipelines from forks of a public project.
To see the group membership yourself, run:
id gitlab-runnerWhat jobs are allowed to do
Registration writes the job settings into the runner's configuration when you connect it. You can read them back at any time:
sudo /usr/local/sbin/gitlab-runner-posture-check.shRegistration refuses four things, each for a reason:
- A non-
dockerexecutor. Theshellexecutor would run job scripts directly on this instance, as the runner's own account, outside every protection above. - Privileged containers. A privileged job container is effectively the host.
- Host bind mounts. Any volume entry that maps a host path into the job is refused, the Docker socket being only the best known. Jobs get a Docker-managed
/cachevolume 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. On a larger instance, raise the limit to run jobs in parallel:
sudo grep -E '^(concurrent|check_interval)' /etc/gitlab-runner/config.tomlKeeping 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/52sac-gitlab-runner-origin apt-cache policy gitlab-runner | sed -n '1,3p'The package is pinned within the 19.x line. A runner has to be compatible with the GitLab instance it serves, so a major-version jump is worth making deliberately:
cat /etc/apt/preferences.d/gitlab-runnerWhen your GitLab is ready for a new major version, edit that pin, then run sudo apt-get update && sudo apt-get install --only-upgrade gitlab-runner gitlab-runner-helper-images.
Move the runner to another instance
The runner refuses to re-register somewhere new on its own. Otherwise it would abandon the runner your first GitLab still lists, while this instance quietly stopped claiming its jobs. To move the runner deliberately, disconnect it on the instance:
sudo /usr/local/sbin/gitlab-runner-disconnect.shThis command 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 command instead of running gitlab-runner unregister yourself. The runner runs as an unprivileged account, and unregister run under sudo rewrites /etc/gitlab-runner/config.toml as root. That leaves the service unable to read its own configuration. The command above does the same work and keeps the ownership correct.
Troubleshooting
gitlab-runner.service is inactive and systemctl start does nothing. This is the shipped state before you connect the runner. Check what the start 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
- An instance URL that points at a project rather than the instance root
The service log says which:
sudo journalctl -u gitlab-runner-register.service -n 30 --no-pagerJobs stay queued. If you created the runner 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 that 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, as 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.shSupport and licensing
SecureAnyCloud provides 24/7 technical support for this image by email and live chat: sales@secureanycloud.com or https://secureanycloud.com/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 in your request.
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). SecureAnyCloud is not affiliated with, endorsed by, or sponsored by GitLab Inc.
Deploy on AWS
Launch GitLab Runner 19.5.3 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