Gitea is a popular choice for self-hosted Git repositories. It has a VERY small footprint and offers comparable features to GitLab without the bloat. For single developers or small teams, Gitea is a great choice, and I’ve been using it for over 3 years now. Since 2023, Gitea comes with a built-in CI/CD solution called Gitea Actions (obviously inspired by GitHub Actions), mostly compatible with GitHub Actions. In this post, I’ll give you a quick overview of this feature and what my experience has been so far.
I believe, for better or for worse, that GitHub is THE place for open-source projects, at least right now.
It’s where most of the open-source projects are hosted and where most of the developers are.
If you are doing open-source work, you will have a GitHub account, you will be familiar with GitHub’s interface and workflows.
I’m not going to dig through mailing lists, send commits via email or create an account, just to tell you that a specific sequence of actions crashes your software.
But praising GitHub is not the point of this post, in fact, I’m not a big fan of centralization in general, but I am also pragmatic.
Instead, I want to discuss a situation where you want to host your own Git repositories, but still want to use the same CI/CD system that you are familiar with.
Why not use private repositories on GitHub?
Private repositories might not cut it, and CI minutes and storage are even more limited for private repositories.
Storing multiple gigabytes of repository data and another gigabyte of artifacts will easily exceed all limits of any tier.
Additionally, you probably want to have a backup plan in case GitHub goes down or starts charging for private repositories.
A while ago, I started to gain an interest in decompiling and modding old games.
Git LFS is a great tool for storing large binary files, which is perfect for storing game assets and compiled binaries.
It allows me to keep track of both wanted and unwanted changes, even if these assets are several gigabytes in size.
Likewise, I don’t have to worry about legal issues with distributing copyrighted game assets.
All of this makes Gitea a great choice, and it recently got even better with the addition of Gitea Actions.
Gitea Actions
Gitea Actions is a CI/CD system that is mostly compatible with GitHub Actions.
It is based on nektos/act, a tool that allows you to run GitHub Actions locally, primarily intended for testing and debugging your workflows.
The team behind Gitea has created act_runner, a coordinator that connects to your Gitea instance and runs your workflows by spawning act
instances.
While act
is not an official product from GitHub, due to its main purpose being testing workflows, act tries to be as compatible as possible with GitHub Actions.
The last piece of the puzzle is the fact that enterprise customers can run GitHub Actions on their own infrastructure, which requires all GitHub Actions to be aware of different API endpoints across distributed systems.
This allows Gitea Actions to redirect most of the GitHub Actions API calls to the Gitea instance, making it a drop-in replacement for GitHub Actions, which might actually be a bit overpromising, as we will soon see, but you get the idea.
Setup
I’m not going to go into detail on how to set up Gitea, as there is an official guide out there, but I will leave a few notes on how I set up my Gitea instance.
The idea is similar to GitLab: You allow users to add runners to the Gitea instance, which can then be used to run workflows.
Runners can be scoped to the entire instance, a specific organization, or a specific repository.
In my case, I run all my services on a single Docker host, including Gitea, so I decided to run the runner in a Docker container, which can be as simple as adding another service to your docker-compose.yml
file.
services:
gitea:
[...]
runner:
image: "gitea/act_runner:latest-dind-rootless"
# act_runner rootless runs act_runner and local dind, which needs privileged status
privileged: true
restart: always
environment:
# dind rootless image actually has wrong path
- "DOCKER_HOST=unix:///run/user/1000/docker.sock"
- "GITEA_INSTANCE_URL=https://gitea.example.com"
- "GITEA_RUNNER_NAME=runner"
# set once for setup, token is one-time use
# - "GITEA_RUNNER_REGISTRATION_TOKEN=<token>"
networks:
# only for outbound connections, not shared with gitea
- runner
volumes:
- 'runner-data:/data'
volumes:
runner-data:
[...]
Notes
Something not immediately obvious is that this image (specifically the -dind-rootless
variant) requires privileged status, as it runs a Docker-in-Docker (DinD) setup.
The actual architecture of the runner requires a Docker socket to spawn containers, for running act
.
There is also an option to run act
bare-metal, but I’m not.
So the runner container contains two services: the act_runner
and a DinD daemon.
If you are familiar with microservices, you will notice that running two services in a single container is not ideal.
Furthermore, this gives the act_runner
access to a privileged environment, which it does not need (only the DinD daemon needs it).
You can use a separate DinD container, and pass only the Docker socket to the runner container, but at the time of writing, the runner image does not support this setup and I would rather not maintain another forked image.
This ensures some form of isolation between the runner and the host system (more on that later).
You could pass the Docker socket from the host to the container, but then you would not only give the runner direct access to the host system (which it technically still has), you would also pollute the host system with potentially harmful containers.
So we go with the DinD setup and I choose not to use any permanent volumes besides the one for the configuration, as everything else is just for caching and can be recreated at any time.
The name runner
might be somewhat misleading, as it is not the runner itself, but the coordinator that connects to the Gitea instance and spawns the actual runners.
I don’t have a better name and for all intents and purposes, it runs the workflows, so I’ll stick with it.
Also note that the runner does not share an internal network with the Gitea instance, as this can result in a security risk, as both the runner and the Gitea could bypass the edge router and communicate directly with each other, despite not needing to.
Instead, the runner is connected to a separate network, which only allows outbound connections, so the runner can only communicate with the Gitea instance via the edge router, which is also the only way that actual workflows can reach Gitea’s API for in-workflow interactions.
Now in theory, if you had multiple users, they would all register their own runners, and you would probably not have the runner next to the Gitea instance within the same docker-compose.yml
file.
But this is a single user setup, so I’m fine with this.
Power to the token
Having set up the runner, I was hyped to just import my existing testing ground for GitHub Actions and see it run on Gitea.
Gitea encourages you to use the GITEA_
prefix where you would usually use GITHUB_
in your workflows, but for compatibility reasons, it will also accept the GITHUB_
prefix.
I found that it’s usually better to stick with the GITHUB_
prefix, as using the GITEA_
prefix has caused the runner to expect other variables to be also prefixed with GITEA_
, which might not be the case.
But whatever you choose, an unmodified GitHub Actions workflow should run on Gitea Actions without any issues.
This is precisely what I did, when my luck for discovering edge cases struck: The default runner token, used for checking out the repository, only has permissions to read the repository.
The runner token used by GitHub Actions has more permissions and can even push new images to the GitHub Container Registry.
Because, if you didn’t know, Gitea has, among other things, a built-in Docker registry.
One would expect that it behaves similar to GitHub’s registry, which allows workflows to push images into the namespace of the repository’s owner.
Instead, the Gitea registry is pretty much its own thing, and while you can associate a repository with a Docker image, you cannot push images to it via the runner token.
Note that I use the term namespace here, instead of differentiating between organizations and users, as they are effectively the same thing.
This comes down to multiple decisions made by the Gitea team, mainly motivated by security concerns over interactions between forks and pull requests within the same Gitea instance.
On our well-trusted, single user instance, this is not a concern, but we need a solution.
Gitea doesn’t have a user for performing workflow tasks, so the solution is to create a new token for your account, then use this token in your workflows as a secret.
You can either add the secret to whatever namespace is owning the repository, or you can add it to the repository itself.
Just note that secrets with the same name will overwrite each other, as described in the documentation:
If a secret with the same name exists at multiple levels, the secret at the lowest level takes precedence. For example, if an organization-level secret has the same name as a repository-level secret, then the repository-level secret takes precedence.
If I still had the original logs and outputs from this process, I would show them here, but I don’t, so instead let’s simply pretend that this was a smooth process and I didn’t spend hours debugging.
Running awesome workflow
working 10%..20%..30%..40%..50%..60%..70%..80%..90%..100%
done
Gitea is currently tracking multiple loosely related issues, which are all related to the permissions of the runner token, so this might be fixed in the future.
This would be much more practical, as it would disconnect the runner token from the user account and allow for more fine-grained control over the permissions, since the solution I presented will give the runner full access to the user account.
Down the submodule rabbit hole
With the runner set up and the workflows running, the next step is to use submodules, obviously.
Submodules are interesting, as they immediately expose two challenges:
- We wanted to use private repositories, this includes submodules.
- You usually reference them via SSH, unless you are a monster. But the runner does not have access to your SSH keys, so it has to use HTTPS.
Gitea has a feature behind the REQUIRE_SIGNIN_VIEW
setting, which locks down the entire instance, so that only signed-in users can view repositories.
In this configuration, you cannot have any public repositories, which is desirable for me, but immediately creates fun challenges.
Surprisingly, Git has an answer to this: insteadOf
:
git config --global url."https://gitea.example.com/".insteadOf "git@gitea.example.com:"
Which results in this hilarious .gitconfig
:
[url "https://gitea.example.com/"]
insteadOf = git@gitea.example.com:
Surprisingly, the default actions/checkout
action does already do this for you, solving the first challenge.
The second challenge is solved by using a custom token, as described above, which we can easily pass to the checkout action like so:
- name: Checkout
uses: actions/checkout@v2
with:
token: ${{ secrets.ACCESS_TOKEN }}
submodules: recursive # default is false
No problems here, everything works as expected.
The reason I even took you on this detour is what made me reconsider the entire GitHub Actions ecosystem, but I’ll get to that shortly.
Large File Sorrows
I mentioned earlier that I want to use Git LFS for storing large binary files.
Usually LFS has the drawback of not keeping the entire history locally, making you susceptible to data loss if someone deletes the remote repository, you lose access to your account, or the LFS server goes down.
Since we run our own Gitea instance, we don’t have to worry about that, we can just create a backup.
This is where the trouble begins: Git LFS checkouts work by first acquiring a token for the LFS server, then using this token to download the actual files.
The authorization token is separate from the token used to access the repository.
Now what the actions/checkout
action does is to add a static authorization header to all requests aimed at github.com
, which does not include the GitHub LFS server, since it is on a different domain.
git config --global http.https://github.com.extraheader "AUTHORIZATION: basic $TOKEN"
Git will respect this setting above all else, even if it means sending duplicated authorization headers within the same request.
This setting is simply not meant to be used in this way.
The result is that the LFS server will reject the request, as it violates the HTTP specification.
For GitHub Actions, this goes unnoticed, as the LFS server is on a different domain, but for Gitea Actions, it breaks the entire checkout process.
The proper solution would be to use the git-credential-store
, which someone even suggested.
I do have a proper solution in a moment, but I don’t want you to miss out on the temporary solution I came across and used for a while:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fix lfs checkout
run: |
function EscapeForwardSlash() { echo "$1" | sed 's/\//\\\//g'; }
readonly ReplaceStr="EscapeForwardSlash ${{ github.repository }}.git/info/lfs/objects/batch"; sed -i "s/\(\[http\)\( \".*\)\"\]/\1\2`$ReplaceStr`\"]/" .git/config
# tag executions will use wrong ref_name
/usr/bin/git lfs fetch origin refs/remotes/origin/${{ github.ref_name }}
/usr/bin/git lfs checkout
- name: Fix submodules
uses: chrisliebaer/SSH-to-HTTPS@v2 # I don't remember if this was actually necessary
with:
github_token: ${{ secrets.ACCESS_TOKEN }}
- name: Clone submodules
run: |
# TODO: make shallow clone
git submodule update --init --recursive
At this point, you might be rightfully asking yourself why I wouldn’t just write a manual git clone
command, but I was already too deep into the rabbit hole and wanted to see if I could make it work.
I also specifically wanted to stay as close to the original GitHub Actions workflow as possible, meaning that I had to make the actions/checkout
action work, even if it meant using a sledgehammer to crack a nut.
The solution
I combined a cohesive solution for the issues mentioned above, into a single action, which you can find here: chrisliebaer/gitea-actions-fix
.
This action replaces the git
binary with a wrapper script that fixes all the issues I mentioned above by intercepting the destructive calls and replacing them with the correct ones.
The README contains a detailed explanation of what it does and how it works, so I won’t go into detail here.
With this weapon in my arsenal, the aforementioned workflow now simply looks like this:
steps:
- uses: chrisliebaer/gitea-actions-fix@v1
with:
token: ${{ secrets.ACCESS_TOKEN }}
- uses: actions/checkout@v4
with:
token: ${{ secrets.ACCESS_TOKEN }}
submodules: recursive
lfs: true
- name: Most perfect build steps
[...]
This is much cleaner and more maintainable than the previous solution, if you ignore the absolute hack that is safely stored inside my gitea-actions-fix
action.
There was also a surprising amount of issues I ran into when trying to create this action, but that’s an entirely different topic.
And with that, I have a working CI/CD system that is mostly compatible with GitHub Actions, but with the added benefit of being self-hosted and having no limits on storage or CI minutes.
There are other differences and the Gitea team has a list of them that you should be aware of.
Gitea Actions in practice
This brings me to the part where we can enjoy the fruits of our labor.
I’ve been using Gitea Actions for a while now, and I’m very happy with it.
So far, I have not encountered any other issues, but I’m also primarily building Docker images and have not yet interacted with the Gitea API in my workflows.
From skim-reading the documentation, it appears to be mostly compatible with GitHub, but there is also a quite excellent CLI tool which some Gitea actions appear to be using for more advanced workflows.
Security and isolation
If we assume to be the only user, security comes down to the code we run in our workflows.
With no adversaries trying to manipulate our workflows, this leaves us with the security of outside GitHub Actions.
Since the big advantage of GitHub Actions is the vast amount of ready-to-use actions, you are likely to make use of this fact.
Actions
It is reasonable to assume that very popular actions such as actions/checkout
, docker/setup-buildx-action
, or docker/login-action
are safe to use, as they are used by many people and are likely to be audited.
Likewise, if you are using a specific product or library, using their official actions is unlikely to open up any additional threat vectors.
For the remaining actions, you are left with the same implications as with running GitHub Actions on GitHub.
It’s always a good idea to check the source code of an action before using it, especially if it is not widely used.
Worst case, and assuming all sandbox restrictions are in place, an action gets full access to whatever the token has access to, which, if you followed my advice from earlier, is the entire Gitea user account.
Not good, but you are alone on your instance, so it’s not the end of the world.
On the host side, each runner is spawned in its own container but with access to the DinD daemon, which is shared between all runners and required, since GitHub Actions are permitted to run Docker images themselves.
In a rootless setup, the DinD daemon is rootless, which means that this is where potential exploits should end.
Since this container is privileged, it could potentially escape its own container, but only if the rootless context can escalate to root permissions.
I consider this to be a very unlikely scenario and am much more concerned about the actual actions that are run, which I luckily have full control over.
Multi-user scenarios
Let’s widen our scope and assume that we are not the only user on the Gitea instance and instead have multiple (trusted) users, with trusted meaning that we assume they are not explicitly trying to harm us.
If each user has their runner, the security implications are the same as before, and each user has to only worry about the security of their workflows.
A more interesting scenario is when runners are shared between users.
Since the runner has access to the DinD daemon, it can modify other workflow executions, both current and future.
But even if your users don’t have malicious intent, act_runner
also shares the toolcache between all runners.
This cache is used to speed up workflow executions by caching the download of tools and dependencies, usually done by setup-
actions, and is therefore writable by workflows.
Given these circumstances, accidental or intentional data leaks are not only possible, but likely.
The best course of action is to have a separate runner for each user, or be in an environment where you wouldn’t mind if any repository were accessible to any user.
Conclusion
After some questionable decisions and a few hours of debugging, I am able to run my GitHub Actions workflows on my self-hosted Gitea instance.
I don’t need to unify multiple different build systems, as I can use the same workflows on both GitHub and Gitea.
I have to make small adjustments, but I still have one source of truth.
Working with large repositories and hogging CI workers for hours works great, and you really start to notice the difference between a beefy server, compared to GitHub’s underpowered runners.
I do have some concerns about the longevity of Gitea Actions.
The developers have done an excellent job so far, but it is also apparent that many features are still missing and difficult to implement.
I am convinced that this is the right way to go for Gitea, but the road ahead is long and bumpy.
Comparison with other CI/CD systems
This brings us to the question: How does Gitea Actions compare to other CI/CD systems?
I did take a look at both Drone, Jenkins and GitLab CI before I decided to go with Gitea Actions.
I will exclude Jenkins from this comparison, as it is highly outdated and better left in a museum, nothing in its feature set seems desirable to me.
Drone
Drone is a very modern choice and offers first party integration for Gitea.
Like the others, it is configured via YAML files, contains various steps and can run automatically on various triggers.
Its documentation is excellent, and it appears easy to set up.
A recent change in ownership has increased the chances of it being eventually monetized.
You can use Woodpecker, a fork of Drone.
When using Drone, you will most likely build your own steps from scratch, as it doesn’t really have an established ecosystem of actions.
If you are using third-party actions, they are going to be Docker images, which are often nothing more than just a container for binaries.
GitLab CI
I haven’t used GitLab CI in a while, but I remember it being very straightforward to use.
It is again configured via YAML files, with most steps being written from scratch with optional Docker images.
Very similar to Drone, with not that many differences in terms of pre-built actions.
For complex tasks, you run a Docker image and that’s it.
GitLab CI is obviously well integrated with GitLab, so you usually have other reasons to use it, since it is much more feature-rich than Gitea.
But I also found that GitLab is extremely bloated and slow, to a point where it is simply wasting CPU time with busy waiting.
For a service, I may be use a few hours per week, I would rather not have it running 24/7.
What is there to gain?
Note how all the issues I faced were related to the compatibility with GitHub Actions, never with actually running the pipelines.
Not once did the runner not connect, drop a workflow, or fail to start a step.
So if all you need is a way to start programs or scripts, it will just work.
And if you have paid attention, you will remember that Gitea Actions can also run Docker images as part of the workflow.
This means that you are not going to miss out on any features that other CI/CD systems offer, since they all are just glorified ways to run Docker images.
What makes Gitea different is that it will be able to run most GitHub Actions on top of that.
For the majority of use cases, you can simply stay within the GitHub Actions ecosystem and are free to incorporate other steps in the form of Docker images, as you would with Drone or GitLab CI.
So if you are already using Gitea, I see no reason not to use Gitea Actions.
It instantly provides you with access to the largest CI/CD ecosystem out there, making it easy to onboard new users and to maintain a presence on both GitHub and your Gitea instance with the same workflows.
Forgejo
There is one last thing I quickly want to touch on: Forgejo.
Forgejo is a fork of Gitea, created under circumstances I still don’t fully understand.
It used to be a soft-fork, but has recently announced that it will break compatibility with Gitea.
The biggest letdown for me is that Forgejo is using their own platform for development.
As I mentioned in the beginning, it’s important to be where the people are, and that’s not on Forgejo.
I also have some other minor gripes with Forgejo, but these are more personal and not really relevant to the discussion.
And that’s basically the end of the story for me.
I do keep an eye on it, and I would consider switching to Forgejo if it addresses some issues, I described, quicker than Gitea.
Until then, I’m happy with Gitea.