Gitea Runner 5.4.1 on AWS User Guide
Overview
This guide covers launching and using the Gitea Runner 5.4.1 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.
Gitea Actions splits into two halves. Your Gitea instance stores the code, the workflow files 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, workflows queue and nothing happens. Gitea Runner is the official runner, published by the Gitea project. It speaks the workflow syntax teams know from GitHub Actions and runs each job in its own container on the instance.
The cloudimg image adds:
- Gitea Runner 5.4.1 as a single binary at
/usr/local/bin/gitea-runner, run by systemd as the unprivilegedgitea-runneraccount, checked against the release's signed SHA-256 sums. - Docker from the Ubuntu archive, so the container engine gets Ubuntu security updates through unattended upgrades along with the rest of the system.
alpine:3.22already present with every package upgraded, so a first workflow runs without reaching a registry.- Registered to nobody. The image holds no registration, no runner token 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 Gitea.
- A unique runner name made on your instance's first boot, so two runners on one Gitea never collide.
- Jobs run in containers only, without the Docker socket or the host's root filesystem. The runner is configured with
docker_host: "-", so Docker never mounts its socket into a job container, and every label must name adocker://image: a label ending in:host, which would run steps on the instance itself, is refused. Jobs cannot bind-mount host directories or run privileged, and workflow container options such as--privileged,--network hostor--pid=hostare stripped. cloudimg's build proves this by running hostile jobs that ask for each of these and checking every container they start. - The
gitea-runneraccount is in thedockergroup, 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 workflows 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 build cache listens only for job containers.
- Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.
Trademark notice. Gitea is a trademark of its respective owner. "Gitea" and "Gitea 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 the Gitea project.
Prerequisites
- An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to your Gitea instance and to the container registries your workflows use.
- A Gitea 1.21 or later, or Forgejo, instance with Actions enabled, and permission there to create a runner registration token (site administrator, organisation owner or repository administrator).
- 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 Gitea instance. Do not open port 8088; that is the build cache, and only job containers on the instance use it.
Step 1: Launch the instance
In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Gitea Runner 5.4.1 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 workflows 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=gitea-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 |
|---|---|
| Gitea Runner 5.4.1 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.
gitea-runner --version
sudo docker version --format '{{.Server.Version}}'
systemctl is-active docker gitea-runner-egress-filter
systemctl is-enabled gitea-runner.service
systemctl is-active gitea-runner.service || echo "(inactive is the correct state until the runner is connected)"
gitea-runner.service reporting inactive while is-enabled reports enabled is the correct state, not a fault. The service has a condition on the registration file, and systemd skips it until that file exists.
One container image is present, so a workflow can run straight away:
sudo docker image ls
Step 4: Check what the network can reach
sudo ss -Hltn | awk '{print $4}' | sort -u
Only SSH is listening. The bundled checker asserts that set by equality rather than by looking for a few unwanted ports:
sudo /usr/local/sbin/gitea-runner-port-check.sh
And this confirms the image is connected to nothing: no registration file, no runner token and no remembered instance address.
sudo /usr/local/sbin/gitea-runner-registration-assert.sh
Step 5: Your instance's runner identity
Every instance from this image makes its own runner name on first boot, so a second instance you launch does not collide with the first.
sudo grep -vE '^#' /root/gitea-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 '^GITEA_RUNNER_NAME=' /root/gitea-runner-credentials.txt | cut -d= -f2-)"
echo "This instance will register as: ${NAME}"
echo "Recorded for this instance: <GITEA_RUNNER_NAME> at <GITEA_RUNNER_HOST>"
Step 6: Get a registration token from your Gitea instance
A registration token is a short-lived value that the runner exchanges, once, for its own long-lived token. Create one at the scope you want this runner to serve:
| Scope | Where to create it |
|---|---|
| Every repository on the instance | Site Administration → Actions → Runners → Create new runner |
| One organisation | Organisation Settings → Actions → Runners |
| One repository | Repository Settings → Actions → Runners |
Copy the token, and note the root URL of your instance (https://gitea.example.com/, not the URL of a repository).
Step 7: Connect the runner to your instance
Put the two values in /etc/gitea-runner/registration.env and start the registration service:
sudo tee /etc/gitea-runner/registration.env >/dev/null <<'EOF'
GITEA_INSTANCE_URL=https://<your-gitea-host>/
GITEA_RUNNER_REGISTRATION_TOKEN=<your-registration-token>
EOF
sudo chmod 0600 /etc/gitea-runner/registration.env
sudo systemctl start gitea-runner-register
Registration hands the token to the runner through a file readable only by the runner's account, never on a command line, so it does not appear in the process list or the journal. It writes /var/lib/gitea-runner/.runner, removes the spent token from registration.env and starts the runner. Confirm it connected:
systemctl is-active gitea-runner.service
sudo journalctl -u gitea-runner.service -n 20 --no-pager
A line reading declare successfully means your instance has accepted this runner. It now appears under Runners in your Gitea settings with the name from Step 5 and the labels from Step 10.
Registering at launch with user data. You can also put the same two lines, in the same KEY=VALUE form, in the instance's user data at launch, and the runner connects on its very first boot. User data stays readable through the EC2 API to anyone allowed to describe the instance's attributes, and from the instance metadata service to any process on the instance itself (the Step 9 firewall rule refuses it to job containers, and systemd to the runner's own services, as defence in depth). If that matters to you, leave the token out at launch and write it into registration.env afterwards instead. A registration token is single use either way: once the runner has exchanged it, it is spent.
Step 8: Run your first workflow
In any repository the runner serves, commit .gitea/workflows/hello.yml:
name: hello
on: [push]
jobs:
hello:
runs-on: alpine-3.22
steps:
- run: echo "Hello from $(hostname)"
- run: head -2 /etc/os-release
Push it. The run appears under the repository's Actions tab, and the runner claims it within a couple of seconds.
runs-on: alpine-3.22 selects the image that ships on the instance, so this first workflow needs no registry access. runs-on: ubuntu-latest also works and is what most existing workflows use: it pulls the Gitea runner image on first use, which takes a few minutes once and is then cached.
Step 9: Check what a job can and cannot reach
A runner executes code from anyone who can push to the repositories it serves, so it is worth knowing exactly what a job can and cannot do. This runs a real workflow job in a real container and reports back:
sudo /usr/local/sbin/gitea-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 workflow step cannot create containers of its own or take over the host; and that the job's requests to the instance metadata service were refused, both a plain request and a request for an IMDSv2 token. 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 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/gitea-runner-egress-filter.sh check
The runner depends on this rule: gitea-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 gitea-runner.service | grep -o 'gitea-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 gitea-runner gitea-runner-register docker containerd
For the whole posture in one command:
sudo /usr/local/sbin/gitea-runner-verify.sh
Who to trust with this runner. The gitea-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 root filesystem, because the runner runs with docker_host: "-" and refuses :host labels, and cloudimg's build proves both with hostile jobs; the metadata firewall rule is defence in depth on top. None of this makes it safe to run workflows 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;
- register the runner only to Gitea or Forgejo instances, and serve only organisations and repositories, whose workflow authors you trust. A repository- or organisation-level runner is a smaller audience than an instance-wide one, and a public repository that runs workflows from forks is not a trusted audience.
You can see the group membership and the socket setting for yourself:
id gitea-runner
sudo grep -n 'docker_host' /etc/gitea-runner/config.yaml
Step 10: Choose what jobs run inside
A label maps a runs-on: value to the container image that job runs in. The shipped set is in /etc/gitea-runner/config.yaml:
grep -A5 '^ labels:' /etc/gitea-runner/config.yaml
Every label takes the form name:docker://image. In Gitea Runner 5, a label without a docker:// image runs workflow steps directly on the instance, outside the container and outside every protection in Step 9. That includes a bare name such as ubuntu-latest with nothing after it and any label ending in :host. This image refuses them: the label check below fails, and so does registration.
To change the labels, edit the list in config.yaml, check it and restart the runner. The runner declares its labels to your Gitea each time it starts, so no re-registration is needed:
sudo nano /etc/gitea-runner/config.yaml
sudo /usr/local/sbin/gitea-runner-label-check.sh
sudo systemctl restart gitea-runner.service
sudo /usr/local/sbin/gitea-runner-label-check.sh
Labels are set in config.yaml only. GITEA_RUNNER_LABELS in registration.env is refused with an explanation, because Gitea Runner 5 ignores registration-time labels when the config file sets them. Values in config.yaml may not contain $, since Gitea Runner 5 expands ${NAME} there.
Jobs run one at a time (capacity: 1), which suits m5.large. Raise it in config.yaml on a larger instance if you want parallel jobs.
Step 11: Build caching
actions/cache works out of the box. While the runner is connected it runs a cache server on port 8088, and a firewall rule accepts that port only from the Docker address pool, so job containers can reach it and nothing off the instance can:
sudo iptables -S INPUT | grep 8088
Keep port 8088 closed in your security group as well. Cached data lives under /var/lib/gitea-runner.
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 Gitea still lists while this instance quietly stopped claiming its jobs. To move it deliberately:
- Delete the runner on the old Gitea's Actions → Runners page.
- On the instance, stop the runner and remove its registration (the file belongs to the runner's account):
sudo systemctl stop gitea-runner.service
sudo -u gitea-runner rm -f /var/lib/gitea-runner/.runner
- Repeat Step 7 with the new instance URL and a token from that instance.
Step 13: Upgrades
Ubuntu and Docker update through Ubuntu's unattended upgrades, including the container engine, because this image uses the archive docker.io package rather than a third-party repository that unattended upgrades would not cover.
To upgrade the runner itself, download a newer release from gitea.com/gitea/runner, check it against the release's checksums.txt, replace /usr/local/bin/gitea-runner and restart:
sudo systemctl restart gitea-runner.service
gitea-runner --version
Read the release notes before crossing a major version: a runner must be compatible with the Gitea instance it is registered to, and Gitea Runner 5 changed how labels without a schema behave (Step 10).
Troubleshooting
gitea-runner.service is inactive and systemctl start does nothing. That is the shipped state before registration. Check whether the registration file exists:
sudo -u gitea-runner test -e /var/lib/gitea-runner/.runner && echo "registered" || echo "not registered yet"
systemctl show gitea-runner.service -p ConditionResult --value
If it is not registered, go back to Step 7.
Registration was rejected. The usual causes are an expired or already-used registration token, an instance URL that points at a repository rather than the instance root, or Actions not being enabled on the instance. The service says which:
sudo journalctl -u gitea-runner-register.service -n 30 --no-pager
Registration reports that a previous attempt is still running. An attempt against an unreachable instance holds a lock while it retries, for at most three minutes. Wait, then try again.
Workflows stay queued. The job's runs-on: value has to match one of this runner's labels (Step 10), and the runner has to be online on your Gitea's Runners page. Check what this runner declared:
sudo journalctl -u gitea-runner.service --no-pager | grep -m1 'declare successfully' || echo "runner has not declared itself 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 gitea.com >/dev/null 2>&1 && echo "DNS works from a job container"'
Check the whole appliance at once.
sudo /usr/local/sbin/gitea-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 Gitea or Forgejo, workflows and container images, caching, running several runners and upgrades. Include the instance type, the Region and the output of sudo /usr/local/sbin/gitea-runner-verify.sh.
Gitea 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 the Gitea project.