Running Your Own CI Pipeline Without the Cloud Bill
Gitea Actions brings GitHub Actions-compatible workflow syntax to your self-hosted Gitea instance, letting you define CI pipelines in YAML and trigger them on push, pull request, or schedule – without sending your code to a third-party service. For teams running private infrastructure or handling sensitive repositories, that distinction matters more than any feature list can convey. The runner is a lightweight binary that connects to your Gitea server, polls for jobs, and executes them in isolated environments using Docker or a host process.
The appeal here is not just privacy. Self-hosted runners eliminate per-minute billing, let you use whatever hardware you already own, and give you full control over the execution environment – including which tools are pre-installed, how much memory a job can consume, and whether the runner even has internet access. A build that runs on a shared cloud runner at 4 minutes might run in under 90 seconds on a dedicated machine with cached dependencies and a local Docker registry.
Setup takes about 20 minutes if your Gitea instance is already running.
What You Need Before Starting
You need a working Gitea installation at version 1.19 or later – Actions support was added in that release and remained experimental until 1.21, where it was enabled by default. If you are running an older instance, upgrade first. Your runner machine needs Docker installed if you plan to use container-based execution, which is the default and the most isolated option. The runner binary itself has no other hard dependencies.
On the Gitea side, Actions must be enabled in the server configuration. Open your app.ini file – typically located at /etc/gitea/app.ini or /opt/gitea/custom/conf/app.ini depending on your install method – and confirm the following section exists:
- [actions] section present with ENABLED = true
- Gitea service restarted after any config change
- The Actions tab visible in at least one repository settings panel
You will also need a runner registration token. This token is how the runner proves to your Gitea server that it is authorized to claim jobs. Tokens can be scoped to an instance (admin-level), an organization, or a single repository. For a first setup, pull an instance-level token from the Gitea admin panel under Site Administration > Runners > Create Runner. Keep that token ready – you will use it in the next step.
Installing and Registering the Runner
Gitea distributes the act runner as a standalone binary through its forgejo-runner and gitea-act-runner release pages. Download the appropriate binary for your operating system and architecture – most Linux servers will use the linux-amd64 build. Place the binary somewhere on your PATH, such as /usr/local/bin/act_runner, and make it executable with chmod +x. If you are managing your self-hosted services through Docker, a compose-based deployment works equally well and is easier to update.
Run the registration command to connect the runner to your Gitea instance:
- act_runner register – starts the interactive registration flow
- Enter your Gitea instance URL when prompted (e.g., https://git.yourdomain.com)
- Paste the registration token from the admin panel
- Give the runner a name – this appears in job logs and the runner list
- Set labels to declare what environments this runner supports (e.g., ubuntu-latest:docker://node:20)
Labels are more important than they look. When a workflow file specifies runs-on: ubuntu-latest, Gitea matches that string against registered runner labels to decide which runner picks up the job. If no runner carries a matching label, the job stays queued indefinitely. During registration, you can accept the defaults or define custom labels tied to specific Docker images your pipelines actually use. After registration completes, start the runner with act_runner daemon and verify it appears as online in the Gitea admin panel under Runners.
Writing Your First Workflow
Gitea Actions reads workflow files from .gitea/workflows/ inside your repository. The syntax is a close match to GitHub Actions, so existing workflows often work with minimal changes. Create a file like .gitea/workflows/ci.yml and define a basic pipeline. A simple Node.js test run looks like this: set the trigger to push, define one job with runs-on: ubuntu-latest, add steps to check out the code using actions/checkout@v3, install dependencies, and run your test command. Push the file to your repository and watch the Actions tab – the job should appear within seconds of the runner polling.
One practical difference from GitHub Actions: not every public action in the GitHub Marketplace will work out of the box. Actions that make assumptions about the runner environment or call GitHub-specific APIs may fail. The safest approach is to pin well-known actions like actions/checkout and actions/cache, which the Gitea runner handles through its own action cache mechanism. For anything more exotic, test it explicitly rather than assuming compatibility. The runner logs are detailed enough to diagnose most failures directly from the job output view in the Gitea UI.
Caching deserves specific attention. The actions/cache step works, but it caches to your Gitea server rather than a distributed cloud cache, which means cache hits are fast on subsequent runs but the cache storage lives on whatever disk your Gitea instance uses. If you are running multiple heavy pipelines, set a cache size limit in the Actions configuration to avoid unbounded disk growth. The relevant setting in app.ini is ACTION_CACHE_CAP under the [actions] section.
Running the Runner as a System Service
Running act_runner daemon in a terminal is fine for testing, but a production setup needs the runner to survive reboots and restart after crashes. On systemd-based Linux systems, create a unit file at /etc/systemd/system/act_runner.service. The file should define the binary path, working directory, and any environment variables – particularly if your runner needs access to a local Docker socket or a custom config file generated during registration. After writing the unit file, run systemctl daemon-reload, enable the service with systemctl enable act_runner, and start it. Check status with systemctl status act_runner to confirm the runner connects and shows online in Gitea.
If you are already managing your infrastructure through Docker Compose, consider running the act runner as a container alongside your Gitea stack. The official image mounts the Docker socket from the host, allowing the containerized runner to spin up sibling containers for each job – a pattern sometimes called Docker-in-Docker-adjacent, though technically it uses the host daemon rather than a nested one. This approach fits naturally into an existing Compose setup and makes version pinning straightforward. If you use Portainer to manage your Docker environment, you can monitor the runner container alongside your other services from the same dashboard.
Secrets for your pipelines – API keys, deployment credentials, signing certificates – are stored in the Gitea repository or organization settings under Settings > Secrets, and injected into jobs as environment variables exactly as they would be on GitHub. The runner never logs secret values, but the jobs themselves can inadvertently expose them through careless echo statements or verbose build tool output, so audit your workflow steps the same way you would any script that handles credentials.
The runner registration token you used during setup is a one-time credential – once the runner is registered, it authenticates with a separate token stored in the generated config file. Guard that config file with appropriate file permissions, because anyone with read access to it can impersonate your runner and potentially claim jobs from your Gitea instance.
