Deployment guide

Gitea Runner 5.4.1

Overview

Gitea Actions has two halves. Your Gitea instance stores the code, the workflow files and the run history. A runner 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.

This guide covers the Gitea Runner 5.4.1 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 pick it as the software version when you subscribe and launch.

What you get:

  • Gitea Runner 5.4.1 as a single binary at /usr/local/bin/gitea-runner, checked against the release's signed SHA-256 sums and run by systemd as the unprivileged gitea-runner account
  • Docker from the Ubuntu archive, so the container engine receives Ubuntu security updates through unattended upgrades along with the rest of the system
  • alpine:3.22 already 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 does 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
  • Container-only jobs, 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. Every label must name a docker:// image, and 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 host or --pid=host are stripped. SecureAnyCloud's build proves this by running hostile jobs that ask for each of these and checking every container they start
  • The gitea-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. Treat the instance as belonging to the people who write the workflows it runs (Step 9)
  • A firewall rule that refuses job containers access to the instance metadata service, over IPv4 and IPv6, and a systemd rule that denies it 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 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. SecureAnyCloud 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, because it dials out to your Gitea instance. Do not open port 8088. That port is the build cache, and only job containers on the instance use it
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, choose your Region, and select Launch through EC2.

Choose m5.large (2 vCPU, 8 GiB) or larger 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 works 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 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.

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=gitea-runner-01}]'

For an instance that is already running, 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
2

Connect to your instance

The SSH user for Gitea Runner 5.4.1 on Ubuntu 24.04 is ubuntu. Connect with your key pair:

ssh -i <your-key.pem> ubuntu@<public-ip>
3

Confirm the runner is installed and idle

The runner is installed and enabled, but deliberately not running, because it does not yet know where to connect. Check the versions and service states:

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)"

It is correct for gitea-runner.service to report inactive while is-enabled reports enabled. 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
4

Check what the network can reach

List the listening ports:

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

Only SSH is listening. The bundled checker asserts that exact set, rather than searching for a few unwanted ports:

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

Confirm that 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
5

Find your instance's runner identity

Every instance from this image generates its own runner name on first boot, so a second instance never collides with the first. Read the identity file:

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

The file is 0600 root:root and holds no secret, because the 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>"
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 the token at the scope you want this runner to serve:

  • 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. Also note the root URL of your instance (https://gitea.example.com/), not the URL of a repository.

7

Connect the runner to your instance

Write the instance URL and the token to /etc/gitea-runner/registration.env, then 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 passes the token to the runner through a file that only the runner's account can read, never on a command line, so the token does not appear in the process list or the journal. The service writes /var/lib/gitea-runner/.runner, removes the spent token from registration.env and starts the runner. Confirm that 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. The runner now appears under Runners in your Gitea settings, with the name from Step 5 and the labels from Step 10.

Register at launch with user data. You can instead put the same two lines, in the same KEY=VALUE form, in the instance's user data at launch. The runner then connects on its first boot.

User data stays readable through the EC2 API to anyone allowed to describe the instance's attributes. It is also readable from the instance metadata service by any process on the instance itself. As defence in depth, the Step 9 firewall rule refuses it to job containers, and systemd refuses it to the runner's own services. If that matters to you, leave the token out at launch and write it into registration.env afterwards. Either way, a registration token is single use: once the runner has exchanged it, it is spent.

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 the file. 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 most existing workflows use it. It pulls the Gitea runner image on first use, which takes a few minutes once and is then cached.

9

Check what a job can and cannot reach

A runner executes code from anyone who can push to the repositories it serves, so you should know exactly what a job can and cannot do. The probe below runs a real workflow job in a real container and reports the result:

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

The probe verifies behaviour by running jobs, not by reading configuration. It checks three things:

  • The job produced its own output
  • The job container had no Docker socket, so a workflow step 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

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 refusal is a firewall rule on container traffic only, for IPv4 and IPv6. It 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/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. Systemd also blocks the runner's own services from the metadata service:

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

To check the whole security posture in one command, run:

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

Decide whom 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. SecureAnyCloud's build proves both with hostile jobs, and the metadata firewall rule adds defence in depth.

None of this makes it safe to run workflows 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
  • 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 has a smaller audience than an instance-wide one, and a public repository that runs workflows from forks is not a trusted audience

You can check the group membership and the socket setting yourself:

id gitea-runner
sudo grep -n 'docker_host' /etc/gitea-runner/config.yaml
10

Choose what jobs run inside

A label maps a runs-on: value to the container image that the job runs in. The shipped labels are 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 such labels: 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 you do not need to re-register:

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

Set labels 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 $, because Gitea Runner 5 expands ${NAME} there.

Jobs run one at a time (capacity: 1), which suits m5.large. On a larger instance, raise the capacity in config.yaml to run jobs in parallel.

11

Build caching

actions/cache works out of the box. While the runner is connected, it runs a cache server on port 8088. 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 is stored under /var/lib/gitea-runner.

12

Move the runner to another instance

The runner refuses to re-register somewhere new on its own. Doing so would leave a stale runner listed in your first Gitea while this instance quietly stopped claiming its jobs. To move the runner deliberately, follow these steps:

  • 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.
13

Upgrades

Ubuntu and Docker update through Ubuntu's unattended upgrades. The container engine is covered 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 and check it against the release's checksums.txt. Then 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. This 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 the runner 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 enabled on the instance. The service log shows 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 must match one of this runner's labels (Step 10), and the runner must be online on your Gitea's Runners page. Check which labels 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 that 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

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 Gitea or Forgejo, workflows and container images, caching, running several runners, and upgrades.

When you contact support, 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). SecureAnyCloud is not affiliated with, endorsed by, or sponsored by the Gitea project.

Deploy on AWS

Launch Gitea Runner 5.4.1 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

Deploy on AWS