HostiServer
2026-08-31 11:18
GitHub Actions vs GitLab CI vs Gitea Actions in 2026: What to Choose for Self-Hosted CI/CD
📚 "CI/CD from scratch" series, part 6 of 6:
- What CI/CD is: from manual deploy to automated pipelines
- Self-hosted CI/CD: the concept, the architecture and runners
- GitHub Actions self-hosted runner: installation and the first pipeline on a VPS
- GitLab CI self-hosted runner: installation and the first pipeline on a VPS
- Gitea Actions: the Git platform and the pipeline on one server
- GitHub Actions vs GitLab CI vs Gitea Actions in 2026: what to choose for self-hosted CI/CD ← you are here
GitHub Actions vs GitLab CI vs Gitea Actions in 2026: what to choose for self-hosted CI/CD
In the previous three parts, we did the same job three times. The scenario was identical every time: push to main, build, test, ship to production over SSH, verify the app came back up. Only the platform changed.
Now it's worth putting these three solutions side by side. Not to crown a winner, because there isn't one: all three get the job done, and any of them can be brought to a working state. The real question is which one creates the least extra work for your specific team, given its code, its requirements, and how much time it's willing to spend on administration.
1.1 What we're actually comparing
The conditions are the same for all three: code in a private repository, build and deploy running on your own VPS, production sitting on a closed network, SSH access through a dedicated non-root user.
Here's the key thing this series showed in practice: a self-hosted runner answers the question of where the code executes, not the question of where it's stored. These are two separate decisions, and mixing them up is the source of most platform-choice mistakes.
2. The config file and its structure
2.1 Where the configuration lives
| Platform | Path | Number of files |
|---|---|---|
| GitHub Actions | .github/workflows/*.yml |
As many as you like |
| GitLab CI | .gitlab-ci.yml |
One, the rest via include |
| Gitea Actions | .gitea/workflows/*.yml |
As many as you like |
The difference isn't cosmetic. On GitHub and Gitea, pull-request checks, a nightly scan, and a tag-based release each live in their own file, edited independently. On GitLab there's a single entry point, and splitting happens through include, which produces a different effect: configuration is easy to centralize in a dedicated template repository and pull into dozens of projects.
2.2 Hierarchy
GitHub Actions and Gitea Actions share the same structure: on → jobs → steps. There's no such thing as a stage as a separate entity — order is set through needs.
GitLab CI is built differently: stages → jobs → script. Stages are declared explicitly and run sequentially, and there are no steps inside a job — just a list of commands.
# GitHub / Gitea
jobs:
build:
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
# GitLab
stages: [build, test, deploy]
build:
stage: build
script:
- npm ci
- npm run build
The practical consequence shows up on a large pipeline. GitLab's stages read top to bottom as the execution order, which is convenient when someone outside the team reviews the config: an auditor, a new engineer, a client. GitHub's model is more flexible, but to see the order you need to trace the needs chain between jobs.
2.3 The job's environment
On GitLab the image is specified at the job level with the image key, and that's the baseline way of working: a job runs in a container.
On GitHub and Gitea, a container is optional. By default, steps run directly on the runner, and there's a container key at the job level for running inside one. Needed language versions are more often installed via actions like setup-node, setup-python, and similar.
# GitLab: container by default
test:
image: node:20
script: [npm test]
# GitHub / Gitea: version via an action
jobs:
test:
steps:
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm test
2.4 Conditional execution
The logic differs here, and this spot needs attention during migration.
On GitHub and Gitea, the if key sits on a job or a step and evaluates an expression:
deploy:
if: github.ref == 'refs/heads/main'
On GitLab, the rules list is checked top to bottom, the first matching rule wins, and it determines more than just whether the job runs:
deploy:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
- when: never
The difference in capability: rules answers three questions at once — whether the job enters the pipeline, whether it's manual, and whether it blocks the result. On GitHub and Gitea, the same outcome is assembled from several mechanisms: if, workflow_dispatch, an environment with approval.
3. Secrets and variables
3.1 Syntax
GitHub and Gitea access secrets the same way, through a context:
run: ./deploy.sh
env:
KEY: ${{ secrets.SSH_PRIVATE_KEY }}
GitLab substitutes a secret as an ordinary environment variable, with no separate namespace:
script:
- ./deploy.sh
variables:
KEY: $SSH_PRIVATE_KEY
Behind this is a difference in the underlying model. On GitHub and Gitea, a secret is a distinct entity with its own store. On GitLab it's a variable with protection flags, and those flags are what determine how protected it actually is. Hence the core rule from part four: Masked without Protected doesn't protect against anything serious.
3.2 Storage levels
| Level | GitHub Actions | GitLab CI | Gitea Actions |
|---|---|---|---|
| Repository | Yes | Yes | Yes |
| Organization or group | Yes | Yes, inherited by subgroups | Yes |
| Whole instance | Enterprise only | Yes, on self-managed | Yes |
| Environment (staging / production) | Yes, with reviewer approval | Yes, via scope | None |
One row here matters more than the rest. Gitea has no environments, so separating access between staging and production has to be done manually: different secret names, separate repositories, or separate runners with different labels. For a two-person team that's a minor inconvenience; for a process where a production deploy requires sign-off from someone responsible, it's a serious limitation.
ℹ️ On levels in Gitea: older guides sometimes claim secrets there only exist at the repository level. That's outdated: secrets are stored at the user, organization, or repository level, and when names collide, the lower level takes priority. What's genuinely missing is environments.
4. Runner and executor
The execution mechanics are identical across all three, and that's the main takeaway from this series. The agent reaches out to the platform on its own, pulls a job from the queue, executes it, and returns the logs. The platform never "pushes" jobs anywhere, which is why the agent needs no open inbound ports and no public address.
| Parameter | GitHub Actions | GitLab CI | Gitea Actions |
|---|---|---|---|
| Agent | actions/runner | gitlab-runner | act_runner |
| Host execution | Default | Shell executor | A label with :host |
| Container execution | The container key |
Docker executor | A label with docker:// |
| Kubernetes | Via the Actions Runner Controller | Kubernetes executor | None |
| Parallel jobs per agent | One; multiple agents needed | Several, via the concurrent key |
Several, via the capacity key |
| Setting up the service | The svc.sh script |
The package does it for you | The unit is written by hand |
The row on parallelism carries real weight when sizing a server. A single GitHub agent picks up one job at a time, so four parallel builds mean registering four agents. GitLab and Gitea achieve the same thing with a single process and a single config parameter.
The Kubernetes row matters if your builds already live in a cluster. On GitLab it's a built-in executor; on GitHub it's a separate controller; on Gitea you're stuck making do with containers on a VPS.
5. One deploy, three configurations
The task is identical: ship the built files to the server via rsync and restart the service. Only the deploy blocks are shown — build and test look the same across all three.
5.1 GitHub Actions
deploy:
needs: build
runs-on: [self-hosted, linux, deploy]
environment: production
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist/ }
- name: Prepare SSH
run: |
install -m 700 -d ~/.ssh
install -m 600 /dev/null ~/.ssh/deploy_key
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Ship and restart
run: |
rsync -az --delete -e "ssh -i ~/.ssh/deploy_key" \
dist/ deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/
ssh -i ~/.ssh/deploy_key deploy@${{ secrets.DEPLOY_HOST }} \
"sudo systemctl restart app.service"
- name: Clean up the key
if: always()
run: rm -f ~/.ssh/deploy_key
5.2 GitLab CI
deploy-production:
stage: deploy
tags: [self-hosted, shell]
environment:
name: production
url: https://app.example.com
dependencies: [build]
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
before_script:
- chmod 600 "$SSH_PRIVATE_KEY" # a File-type variable
after_script:
- shred -u "$SSH_PRIVATE_KEY" 2>/dev/null || rm -f "$SSH_PRIVATE_KEY"
script:
- |
rsync -az --delete \
-e "ssh -i $SSH_PRIVATE_KEY -o UserKnownHostsFile=$SSH_KNOWN_HOSTS" \
dist/ deploy@$DEPLOY_HOST:/var/www/app/
- |
ssh -i "$SSH_PRIVATE_KEY" -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
deploy@$DEPLOY_HOST "sudo systemctl restart app.service"
5.3 Gitea Actions
deploy:
needs: build
runs-on: deploy # the host-mode label
steps:
- uses: actions/download-artifact@v3 # v4 needs the GitHub API
with: { name: dist, path: dist/ }
- name: Prepare SSH
run: |
install -m 700 -d ~/.ssh
install -m 600 /dev/null ~/.ssh/deploy_key
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Ship and restart
run: |
rsync -az --delete -e "ssh -i ~/.ssh/deploy_key" \
dist/ deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/
ssh -i ~/.ssh/deploy_key deploy@${{ secrets.DEPLOY_HOST }} \
"sudo systemctl restart app.service"
- name: Clean up the key
if: always()
run: rm -f ~/.ssh/deploy_key
5.4 What this shows
- GitHub and Gitea are nearly identical. The differences boil down to the label in
runs-on, action versions, and the lack ofenvironment. - GitLab does more of the key handling for you. The File-type variable creates a temp file on its own, so there are no
echosteps. - The manual step is implemented differently everywhere. On GitLab it's
when: manualin a rule; on GitHub it's anenvironmentwith a reviewer; on Gitea it's a separate workflow withworkflow_dispatch. - Ready-made actions are tempting everywhere. A step using
appleboy/ssh-actionis shorter than manualrsync, but on Gitea it pulls from GitHub, and on a permanent runner any third-party action sees your secrets. For a singlersyncand a singlesshcall, the convenience isn't worth the dependency. - Cleaning up the key is mandatory everywhere. The environment on a self-hosted runner is permanent, and a forgotten key stays accessible to the next job, from whatever branch it comes.
6. Comparison table
| Criterion | GitHub Actions | GitLab CI | Gitea Actions |
|---|---|---|---|
| Config file | .github |
.gitlab-ci.yml |
.gitea |
| Structure | jobs → steps | stages → jobs → script | jobs → steps |
| Secret syntax | ${{ |
$NAME |
${{ |
| Group or organization secrets | Yes | Yes | Yes |
| Environment with approval | Yes | Yes | No |
| Kubernetes executor | Via ARC | Yes | No |
| Ready-made step ecosystem | Thousands of actions in the Marketplace | CI Components and templates | Partial compatibility with GitHub actions |
| Test reports in the UI | Via third-party actions | Built in, visible in the merge request | Limited |
| Deploy history and rollback | Via Environments | Built in, a Rollback button | None |
| Resources for the platform's own server | None needed | 4 GB RAM+ for self-managed | 1 GB RAM+ |
| Where the code is stored | github.com | gitlab.com or your own server | Your server only |
On free minutes, deliberately without numbers: pricing changes faster than articles get updated. The general picture is stable — GitHub gives unlimited minutes to public repositories and a limited quota to private ones, GitLab gives a quota on its free tier, and Gitea counts nothing at all, since everything runs on your own machine. Check current figures on the pricing pages. The bigger point is this: minutes on your own runner don't count against any of the three platforms, so the quota stops being an argument the moment you stand up your own agent.
7. What fits whom
7.1 GitHub Actions
- The project is already on GitHub. The strongest argument, one that outweighs everything else: moving a repository just for CI is almost never worth it.
- Open source. Public repositories get cloud builds with no minute limits, and they don't need — and honestly shouldn't have — a self-hosted agent.
- You need ready-made steps. The Marketplace closes out typical tasks in one line: building images, publishing packages, coverage reports.
- A team with no CI experience. The lowest barrier to entry: the file is edited right in the browser, and errors show up immediately.
Against: on a self-hosted agent, caution with third-party actions becomes mandatory, and some of what's built into GitLab (reports, deploy history) has to be assembled from third-party steps.
7.2 GitLab CI
- The team is already on GitLab. The same argument as above, mirrored.
- Complex pipelines. Stages, a dependency graph, child pipelines, and matrices give you more control on configs with dozens of jobs.
- Many projects with identical settings. Group-level variables and
includefrom a centralized template repository save a noticeable amount of effort. - You need audit and transparency. The explicit stage order reads clearly to an outside reviewer without untangling dependencies, and deploy history shows exactly which commit is on production right now.
- Kubernetes, right now. A built-in executor with no separate controller.
Against: a self-managed GitLab installation is a noticeably heavier service than Gitea. If what you actually need is a closed loop rather than complex pipelines, this option costs more in resources.
7.3 Gitea Actions
- Code can't leave your infrastructure. Contractual obligations, an isolated network, full control over the data.
- A small team and a single server. The Git platform and the runner live on the same VPS, needing fewer resources than GitLab.
- You already have GitHub Actions experience. The syntax carries over almost entirely, with no relearning needed.
- Budget. No per-seat billing, just the cost of the server.
Against: no environments with approval, no deploy history, no Kubernetes, and a limited action ecosystem. Plus all the administration is yours: backups, updates, HTTPS, an actions mirror for a closed loop.
⚠️ The most common choice mistake: moving a repository to a different platform purely for CI features. The cost of migration (history, issues, access rights, team habits, integrations) almost always outweighs the gain. First check whether your problem is already solved by a self-hosted runner on your current platform. In most cases, it is.
8. Conclusion
The series started with the question of what CI/CD even is, and it ends with a choice among three working tools. What's worth taking away as a whole:
- Two separate questions. A self-hosted runner decides where the code executes. Where it's stored is a separate decision, and confusing the two causes most platform-choice mistakes.
- If the code can live in the cloud — use whatever platform the team is already on and add your own agent. It's the cheapest path to controlling resources and reaching a closed network.
- If the code has to stay with you — Gitea for a small team, self-managed GitLab where you need complex pipelines and the full set of built-in capabilities.
- The mechanics are the same everywhere. The agent polls the queue, executes the job, returns the logs. Learn one platform and you can read the others' configs without a dictionary.
- A permanent environment demands discipline. Cleaning up keys, timeouts, limits on parallelism, and caution with third-party actions. That's the price of speed and control.
A practical plan for anyone starting from zero: stand up the simplest possible two-step pipeline on your current platform, get it to a green state, and only then add your own runner. Wire up the deploy last, once the build and tests are already stable. That order gets you a working result in a few days instead of a few weeks spent trying to configure everything at once.
📚 Series navigation:
You're reading part 6 of 6, "GitHub Actions vs GitLab CI vs Gitea Actions in 2026: what to choose for self-hosted CI/CD."
Previous: ← Part 5. Gitea Actions: the Git platform and the pipeline on one server
The series is complete. Start from the beginning: Part 1. What CI/CD is: from manual deploy to automated pipelines
🚀 A server for your CI/CD, whichever platform you choose
GitHub Actions, GitLab CI, or Gitea — the agent runs into CPU, disk, and network in all three cases. Hostiserver gives you predictable resources and a private network you can safely reach production from.
🖥️ Dedicated Servers
- From $90/mo, full control over the hardware for heavy builds and parallel jobs
- NVMe storage: a fast cache for dependencies and Docker layers between runs
- No build-minute limits: you pay for the server, not a platform quota
- Private network between the runner and production servers
- 24/7 support: engineers help with runner setup and deploys
💻 Cloud (VPS) Hosting
- From $19.95/mo, KVM isolation, dedicated vCPU and RAM
- Perfect for your first runner: spin it up, connect it to the repo, try it on a real project
- Easy to scale: separate machines for builds and for deploys, once one stops keeping up
💬 Not sure which option you need?
💬 Reach out and we'll help you figure it out!
Frequently Asked Questions
- Is it worth switching platforms just for CI features?
Almost never. Migration takes commit history, issues, access rights, configured integrations, and team habits with it, and the payoff usually comes down to a handful of conveniences. First check whether your problem is already solved by a self-hosted runner on your current platform: resources, closed-network access, and lifting the minute limit are all available on all three. Real reasons to move are a requirement to keep code in-house and dropping a platform for licensing or cost reasons.
- How hard is it to migrate a pipeline between these platforms?
Between GitHub Actions and Gitea Actions it's almost a copy-paste: fix up
runs-on, the action versions that call the GitHub API, and the toolset in the image. Between GitHub and GitLab there's more work: jobs become jobs,runs-onturns intotags,runsteps land inscript, and everyusesstep gets replaced with commands or a container image. The trigger logic gets rewritten too:ifbecomesrules. For a typical pipeline, that's a matter of a few hours.
- What do I do if I need approval before a production deploy and the platform is Gitea?
Gitea has no environments with reviewers, so the manual step gets built differently. The simplest option is a separate workflow triggered by
workflow_dispatch, started by a button, that picks up a ready artifact. The second option is a tag-based deploy: the rollout only starts once someone creates a release tag, and the right to do that is restricted through branch and tag protection rules. The third is a separate runner with a production label, accessible only to a specific repository.
- Can code be kept in multiple places at once?
Yes, and it's a working setup for a closed loop with a public-facing part. Gitea can mirror repositories in both directions: pulling from an external source or pushing changes to one. A typical use is doing the main work in your own Gitea and mirroring to GitHub for the public side. Keep in mind the pipeline should live on only one side, otherwise a single change triggers two builds and the deploy might fire twice.
- How many runners does a team need?
Go by the number of simultaneous builds at peak hours, not headcount. A team of five engineers usually needs two parallel jobs: one for pull-request checks, another for the default branch. On GitLab and Gitea that's one agent with the right config parameter; on GitHub it's two separate agents. It's better to put the deploy on a separate runner with a different label: it needs access to keys, and mixing it in with builds isn't worth it.
- Is it safe to use third-party actions on your own runner?
More carefully than in the cloud. A third-party action is someone else's code, executing in your environment and seeing the job's secrets, and the server, unlike a one-time cloud machine, keeps living. Working rules: pin actions to a full commit hash instead of a moving tag, restrict which actions are allowed in the settings, and write plain commands yourself for simple steps like
rsyncover SSH. Gitea adds one more thing: actions pull from github.com by default, which needs a mirror for a genuinely closed loop.
- What do you choose when the requirements aren't clear yet?
Stay on whatever platform already holds the code, and start with the simplest possible pipeline: build and test on every push. That's enough to get the core benefit of CI, and enough to figure out what you actually need beyond that. A self-hosted runner gets added as the next step, once it's clear what's missing: resources, network access, or minutes. The question of where the code lives gets decided separately, and usually not by engineers — by the terms of a contract.