Skip to content

Gitea Actions (CI/CD)

RepoFerry’s own repository uses a Gitea Actions workflow (.gitea/workflows/build-and-push.yml) to automatically build and publish the three Docker images whenever a commit lands on main — a safety net on top of the versioned publish done manually by scripts/git/release.sh. This guide covers setting up Gitea Actions on a self-hosted Gitea instance so that workflow (or your own) actually runs, since it doesn’t do anything without a registered runner.

Gitea Actions isn’t on by default. If you run Gitea via Docker Compose with the usual GITEA__section__KEY environment variables, add:

- GITEA__actions__ENABLED=true

to the server service, then apply it:

Terminal window
docker compose up -d

Without Docker Compose (or if you’d rather edit the config file directly — this is also where the environment variable above actually ends up), set it in Gitea’s own app.ini instead:

[actions]
ENABLED = true

app.ini usually lives at /data/gitea/conf/app.ini inside the container (or wherever GITEA_CUSTOM/GITEA_WORK_DIR points to on a non-Docker install). After editing it directly, restart Gitea for the change to take effect — with Docker Compose, docker compose restart server is enough, no need to recreate the container.

Verify it took effect:

Terminal window
docker exec <gitea-container-name> env | grep GITEA__actions
docker exec <gitea-container-name> cat /data/gitea/conf/app.ini | grep -A2 "\[actions\]"

Both should show ENABLED = true / GITEA__actions__ENABLED=true. You should also now see an Actions tab on any repository, and Site Administration → Actions → Runners should load without a 404.

Unlike GitHub Actions, Gitea doesn’t provide any hosted runners — nothing executes until at least one is registered. act_runner (Gitea’s own runner) needs Docker access to actually build and push images. Two ways to run it, pick whichever fits how you already manage the server:

First, get a registration token: Site Administration → Actions → Runners → "Create new Runner".

Gitea publishes an official gitea/act_runner image, which fits naturally alongside a Gitea stack that’s already running in Docker Compose. If Gitea itself runs in the same compose project, add the runner to the same network so it can reach Gitea directly by container name instead of round-tripping through the public domain:

runner:
image: gitea/act_runner:latest
container_name: gitea-runner
restart: always
networks:
- gitea
environment:
- GITEA_INSTANCE_URL=https://your-gitea-domain # the public URL, not the container name
- GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN>
- GITEA_RUNNER_NAME=repoferry-runner
- CONFIG_FILE=/config.yaml
volumes:
- ./runner-data:/data
- ./config.yaml:/config.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock # lets it build/push images with the host's Docker

docker compose up -d registers it automatically on first start (the entrypoint reads those GITEA_RUNNER_* variables) and keeps it running as any other service — no separate systemd unit needed. ./runner-data persists its registration, so it doesn’t re-register on every restart.

You still need a config.yaml next to it (see below) — mounted and referenced via CONFIG_FILE above.

  1. Install act_runner:

    Terminal window
    curl -o act_runner -L https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64
    chmod +x act_runner
    sudo mv act_runner /usr/local/bin/act_runner
  2. Generate a config:

    Terminal window
    act_runner generate-config > config.yaml
  3. Register:

    Terminal window
    act_runner register --instance https://your-gitea-instance --token <TOKEN> --name my-runner
  4. Run it as a service (systemd, so it survives reboots) instead of leaving act_runner daemon in a terminal.

config.yaml: three settings that actually matter

Section titled “config.yaml: three settings that actually matter”

Whichever option you picked, edit config.yaml:

capacity: 3
container:
network: "<your-actual-docker-network-name>"
options: --cpus=2 --memory=4g
  • container.network: the job containers act_runner spins up need Docker access to the same network Gitea itself is on to resolve its internal hostname during actions/checkout — the runner container being on that network isn’t enough, since each job runs in its own separate container. Find the real name with docker network ls — Compose prefixes it with the project name, so it’s likely not just "gitea" (ours turned out to be "gitea_gitea"). Without this, checkout fails with Could not resolve host: <name>.
  • container.options: extra flags passed to docker create for every job container — a good place for resource limits (--cpus/--memory). Don’t add -v /var/run/docker.sock:/var/run/docker.sock here — act_runner already mounts it automatically; repeating it fails with Duplicate mount point.
  • capacity: how many jobs the runner executes at the same time (default 1, i.e. one at a time). If your workflow uses a matrix strategy to build multiple things in parallel (like RepoFerry’s own build-and-push.yml, one job per image), raise this to at least the matrix size, or the jobs just queue on the same runner anyway. Since container.options resource limits apply per concurrent job, capacity: 3 with --cpus=2 each means the host needs up to 6 CPUs free at once.

The exact syntax can shift between act_runner versions — check the current Gitea Actions documentation if something doesn’t match what you see. Restart the runner after editing this file (docker compose restart runner, or restart the systemd service) — just re-running docker compose up -d doesn’t pick up changes to a bind-mounted file’s contents.

Check that it’s actually online: Site Administration → Actions → Runners lists it with a status and a “last online” timestamp. The status may read “Idle” even when everything is fine — that label means “connected, waiting for a job”, not “offline”. What actually matters is the last online timestamp: if it keeps updating to “now” every time you refresh the page, the runner is connected and healthy, regardless of what the status label says. If that timestamp is frozen in the past, that’s the real sign something’s wrong. You can also check the runner’s own logs directly:

Terminal window
docker compose logs -f runner # Option A
journalctl -u act_runner -f # Option B, if run as a systemd service

A runner whose “last online” timestamp never updates almost always means it can’t reach GITEA_INSTANCE_URL — double-check the URL and that both containers are on the same Docker network (Option A), or the --instance value (Option B).

RepoFerry’s own .gitea/workflows/build-and-push.yml is a working example: on every push to main, it logs into Docker Hub, reads the version straight out of backend/build.gradle (not the v<version> git tag, which gets pushed separately and might not exist yet when the workflow starts), and runs scripts/docker/build-and-push.sh <version> to build and publish all three images tagged both :latest and :<version>. It also triggers on push to develop, publishing the same three images tagged :beta instead — meant for a staging environment that always tracks the tip of development. :latest never gets overwritten by a develop push: build-and-push.sh only re-tags :latest when the given tag is a real SemVer version (X.Y.Z), never for beta or any other arbitrary tag.

It needs two secrets, set at repository Settings → Actions → Secrets — never as literal values in the workflow file or anywhere else in the repository:

  • DOCKERHUB_USERNAME — your Docker Hub username.
  • DOCKERHUB_TOKEN — a Docker Hub access token, not your account password: user menu → Account Settings → Personal access tokens → Generate new token. Give it a description, an expiration date, and set Access permissions to Read & Write (needed to push images), then Generate.

Registration tokens, runner tokens, and the two secrets above are all credentials — treat them exactly like any other secret: never commit them, never paste them into an issue/PR/commit message, and rotate them if you suspect they leaked.