Community
0
HostiServer
2026-08-26 11:17

Gitea Actions: The Git Platform and the Pipeline on One Server

⏱️ Reading time: ~11 minutes | 📅 Updated: August 2026

Gitea Actions: the Git platform and the pipeline on one server

In the third and fourth parts, we moved job execution onto our own server. The code itself stayed where it already was: on GitHub or GitLab. For most teams that's the right balance, since only the agent needs administering. But there are cases where that split doesn't work, and the entire system — repository included — moves onto your server.

This part is about Gitea and its built-in CI. We'll spin up a Git platform on a VPS, connect an act runner to it, write a workflow in the syntax you already know from part three, and finish with the same SSH deploy. At the end, we'll look at exactly where the phrase "fully closed loop" stops being accurate unless you deliberately close the gap yourself.

1.1 When a self-hosted runner isn't enough

Your own agent solves the resource and closed-network access problem, but it doesn't change the main thing: the repository, commit history, issues, and code review still live on someone else's platform. Situations where that becomes a blocker:

  • Contractual obligations. A client requires that source code never leave their perimeter, and the contract wording makes no exception for cloud Git hosting.
  • Working without internet access. An isolated environment with no external connections at all, not merely restricted ones.
  • Vendor independence. A change in licensing terms, pricing, or service availability shouldn't be able to halt development.
  • Cost at team scale. Per-seat billing across a few dozen accounts noticeably exceeds the price of a VPS.

1.2 What a fully closed loop gives you

When the Git platform and CI both sit on your server, the external dependency in day-to-day work disappears: development continues even if the outside connection is down. Data from a private repository never gets sent to a third-party service, and access restrictions are defined entirely by your own rules.

The cost of this is direct. You're responsible for backing up the repositories and the database, for keeping the platform updated and its vulnerabilities patched, for the server's uptime during working hours. Cloud GitHub does all this invisibly; your own server needs someone to actually do it.

1.3 What else exists in this space

Tool What it is Built-in CI Best fit for
Gitea A lightweight Git platform written in Go Gitea Actions The experience closest to GitHub
Forgejo A community-led fork of Gitea Forgejo Actions, compatible Priority on a free license and governance model
Gogs Gitea's predecessor, even lighter None Minimal Git hosting with no CI
Woodpecker CI A standalone CI system, a fork of Drone Is itself a CI system Connecting to any Git platform
Drone CI A container-native CI system Is itself a CI system Account for the licensing change after moving under Harness

Here's the fork in the road. Gitea and Forgejo give you two systems in one: Git and CI are set up together, there's a single configuration, and the syntax is familiar from GitHub. Gogs paired with Woodpecker gives more flexibility and independence between the parts, at the cost of two separate services you need to stand up, update, and wire together. For your first self-hosted loop, the first option is simpler, and that's the path the rest of this article follows.

ℹ️ Gitea or Forgejo: technically these are very close systems with a shared origin, and everything in this article works on both. They differ in project governance: Gitea is developed by Gitea Ltd., while Forgejo is a community project under the GPL license. If license independence from a company is critical to you, go with Forgejo, and swap the commands below for their equivalents.

2. Gitea Actions architecture

2.1 Gitea as a Git platform

Gitea is a single Go binary that gives you repositories, issues, pull requests, code review, webhooks, organizations, and access control. In its simple form it runs on SQLite and the filesystem — no separate database, no extra services. That's enough for a team of ten engineers; larger installations move to PostgreSQL.

Resource consumption is noticeably lower than GitLab's: Gitea itself stays within a few hundred megabytes of memory. The difference is that GitLab is a dozen interconnected services, while Gitea is a single process.

2.2 Gitea Actions: familiar syntax

Gitea Actions arrived in version 1.19 and mirror the GitHub Actions model: workflows, jobs, steps, actions via uses. Files live in the .gitea/workflows/ directory, and most of the simple configs from part three carry over without edits.

Compatibility isn't complete, and it's worth knowing the boundaries in advance:

  • Actions are pulled from an external source. By default, uses: actions/checkout@v4 downloads from GitHub. For a closed loop, that has to change — we'll come back to this below.
  • Some features are missing. Matrices work, caching works, but environments with reviewer approval and some of GitHub's more complex constructs are either incompletely implemented or behave differently.
  • JavaScript and composite actions work, and so do container actions. But anything that calls the GitHub API won't work on Gitea.

2.3 The act runner: a separate process

Jobs are executed by the act runner: a standalone program built on the act project, which runs GitHub Actions workflows locally. The logic is the same as in earlier parts: the runner polls Gitea, picks up a job, prepares the environment, executes the steps, and returns the logs.

Execution comes in two flavors. In docker mode, every job runs in a container built from the image specified in the runner's label. In host mode, commands execute directly on the server, with no isolation, so everything the build needs must already be installed.

2.4 Connection diagram

developer
│ git push (SSH or HTTPS)

┌─────────────────────────────┐
│ Gitea │
│ repository + web interface │
│ Actions job queue │
└─────────────────────────────┘
↑ polls the queue over HTTP
│ logs and status
┌─────────────────────────────┐
│ act runner │
│ docker: a container per job│
│ host: commands on the box │
└─────────────────────────────┘
│ SSH

production server

Gitea Actions architecture: the developer pushes code to Gitea, the act runner picks up jobs from the queue and deploys to production

Both components can live on the same VPS, and for a small team that's exactly what happens. Splitting them across two machines makes sense once builds get heavy: the Git interface shouldn't slow down just because a compile is running next door.

3. Installing Gitea on a VPS

3.1 Resources

The bare minimum is 1 vCPU and 1 GB of RAM, and Gitea genuinely runs on that. But budget for the runner separately: builds consume more than the platform itself. Working baselines:

  • Gitea alone, small team: 1 vCPU, 1 GB RAM, 20 GB of disk.
  • Gitea and act runner on one box: 2 vCPU, 4 GB RAM, 40 GB of disk or more.
  • Builds with Docker: 4 vCPU, 8 GB RAM, and separate attention to disk, since images and layers grow fast.

3.2 Installation

The binary route is more transparent when you're getting familiar with the system for the first time:

sudo apt update && sudo apt install git sqlite3 -y

# grab the version and link from Gitea's downloads page
sudo wget -O /usr/local/bin/gitea "<link to the linux-amd64 binary>"
sudo chmod +x /usr/local/bin/gitea
gitea --version

# user and directories
sudo adduser --system --group --disabled-password --home /home/git git
sudo mkdir -p /var/lib/gitea/{custom,data,log} /etc/gitea
sudo chown -R git:git /var/lib/gitea /etc/gitea
sudo chmod 750 /var/lib/gitea
sudo chmod 770 /etc/gitea

The permissions on /etc/gitea are deliberately loose only until installation finishes: the web installer writes app.ini there, after which the directory gets locked down.

The Docker route is shorter and easier to keep updated:

# compose.yaml
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: always
environment:
- USER_UID=1000
- USER_GID=1000
volumes:
- ./gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "222:22"

3.3 The systemd service

This step only applies to the binary installation above. If you set up Gitea via Docker Compose, you don't need a separate systemd unit: restarting and autostarting the container is already handled by the restart: always parameter in the compose file itself.

# /etc/systemd/system/gitea.service
[Unit]
Description=Gitea
After=network.target

[Service]
RestartSec=2s
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/gitea/
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
Restart=always
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now gitea
sudo systemctl status gitea

3.4 First launch

The interface opens on port 3000. The web installer asks for the database type, directory paths, the domain and instance address, and at the bottom of the page, in the optional settings section, an administrator gets created.

Four things worth doing right after installation:

  • Create the admin on the installer page. Otherwise, whoever registers first becomes the admin.
  • Disable open registration. In app.ini, that's DISABLE_REGISTRATION = true under the [service] section.
  • Set up HTTPS. The standard approach is Nginx or Caddy in front of Gitea, with a Let's Encrypt certificate and a ROOT_URL matching your external address.
  • Lock down the config permissions. sudo chmod 750 /etc/gitea && sudo chmod 640 /etc/gitea/app.ini

⚠️ Backups from day one. In a closed loop, there's no copy of your repositories anywhere except your own server. The gitea dump command bundles repositories, the database, and the configuration into a single archive, and it's worth scheduling right away, not after the first incident. Separately, make sure the archive doesn't sit on the same disk as the server itself.

This isn't a substitute for backing up the VPS itself at the hosting-provider level (disk snapshots): a snapshot saves you from a hardware failure or the total loss of the server, while a Gitea dump gives you a portable copy you can redeploy anywhere. It's worth having both. Hostiserver, for instance, offers automatic backups for VPS and dedicated servers — that covers the "the server is completely gone" scenario, while gitea dump covers "I need to move or restore the platform itself."

4. Installing the act runner

4.1 The binary

The act runner is distributed separately from Gitea; grab the link from the project's releases page:

sudo wget -O /usr/local/bin/act_runner "<link to the linux-amd64 act_runner>"
sudo chmod +x /usr/local/bin/act_runner
act_runner --version

Docker mode requires Docker to be installed, and the user the runner runs as needs to be in the docker group.

4.2 Registration

The token comes from one of three places, depending on how broadly the runner needs to serve the system:

  • Repository: Settings → Actions → Runners → Create new runner.
  • Organization: the same path in organization settings.
  • The whole instance: Site Administration → Actions → Runners.
sudo mkdir -p /etc/act_runner && cd /etc/act_runner
act_runner generate-config > config.yaml

act_runner register --no-interactive \
--instance https://git.example.com \
--token <token from the interface> \
--name vps-runner-01 \
--labels ubuntu-latest:docker://gitea/runner-images:ubuntu-latest,deploy:host

Labels work differently here than in earlier parts, and this is exactly where confusion happens most often. A label has three parts: name, execution mode, and image. The entry ubuntu-latest:docker://gitea/runner-images:ubuntu-latest means: a job with runs-on: ubuntu-latest runs inside a container built from that image. The entry deploy:host means: a job with runs-on: deploy runs directly on the server.

This two-label setup mirrors the decision from part four: builds are isolated in a container, while the deploy job gets access to the host's keys and network.

4.3 The systemd service

# /etc/systemd/system/act_runner.service
[Unit]
Description=Gitea Act Runner
After=docker.service

[Service]
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
WorkingDirectory=/etc/act_runner
User=act_runner
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now act_runner
journalctl -u act_runner -f

4.4 Verifying and enabling Actions

In the runner list (Settings → Actions → Runners) the agent should appear with the Idle status and its list of labels. If it's not there, check the service logs: the usual culprits are an unreachable instance address, an expired token, or missing permissions on the Docker socket.

Separately, confirm Actions are enabled at the instance level. On current versions this is the default; on older ones you need an explicit entry in app.ini:

[actions]
ENABLED = true

The Gitea service restarts after this config change. For a specific repository, Actions is enabled separately, under Settings → Repository → Advanced Settings.

5. The first workflow

5.1 Where the configuration lives

Workflow files go into .gitea/workflows/. Gitea also reads the .github/workflows/ directory, which is convenient when migrating from GitHub: the repository moves over as is and runs without touching any paths.

5.2 A basic example

name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'

- run: npm ci
- run: npm run lint
- run: npm test -- --ci

The file is identical to what would work on GitHub Actions. The value in runs-on here isn't the name of a cloud machine — it's the label you assigned to your runner during registration.

5.3 Triggers

push, pull_request, schedule, and workflow_dispatch all work, along with events specific to Gitea, like issues and release.

on:
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**']
pull_request:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:

Scheduling on Gitea has an edge over the cloud version: schedule runs on your own server with no queue, so the run happens right on time.

5.4 First run and logs

After a push, open the Actions tab on the repository: there's a list of runs, jobs, and step logs in real time. The interface is recognizable from GitHub, just simpler.

Two common first-run issues:

  • The job hangs in the Waiting state. There's no online runner with the label given in runs-on. Compare the line in the workflow against the label list on the Runners page.
  • A step with uses fails while downloading. The action is pulled from GitHub, and the server can't reach it. More on this below.

ℹ️ The runner image and container contents. The gitea/runner-images images are noticeably smaller than GitHub's cloud images and carry a basic set of tools. If your steps need rsync, zip, or a compiler, install them as the first step of the job, use a specialized image via the container key, or build your own. It's the same trade-off you get elsewhere in exchange for the system's lightness.

6. A practical example: deploying over SSH

6.1 Secrets in Gitea

The mechanism mirrors GitHub: Settings → Actions → Secrets for values masked in logs, and Variables for ordinary settings. Secrets can be set at the repository, organization, or whole-instance level.

Add three values:

  • SSH_PRIVATE_KEY — the full content of the private key, including the BEGIN and END lines.
  • SSH_KNOWN_HOSTS — the output of ssh-keyscan -H your-server.example.com.
  • DEPLOY_HOST — the production server's address.

The conditions on the production server are the same as in part three: a non-root deploy user, a key in authorized_keys with the from parameter, and a narrow sudoers permission to restart the service.

6.2 The full workflow

name: Build and deploy

on:
push:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'

- run: npm ci
- run: npm run lint
- run: npm test -- --ci
- run: npm run build

- uses: actions/upload-artifact@v3
with:
name: dist
path: dist/

deploy:
needs: build
runs-on: deploy # the host-mode label
steps:
- uses: actions/download-artifact@v3
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 the files
run: |
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key" \
dist/ deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/

- name: Restart the service
run: |
ssh -i ~/.ssh/deploy_key deploy@${{ secrets.DEPLOY_HOST }} \
"sudo systemctl restart app.service"

- name: Verify it came back up
run: |
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health || true)
[ "$code" = "200" ] && exit 0
sleep 3
done
echo "Service isn't responding after the deploy"
exit 1

- name: Clean up the key
if: always()
run: rm -f ~/.ssh/deploy_key

SSH deploy flow in Gitea Actions: the runner builds the project, copies the files to the production server, and restarts the service

Compare this file with the config from part three: the differences boil down to action versions and the label name. That's the main practical value of Gitea Actions — your GitHub experience carries over almost entirely.

The action versions here are deliberately older than on GitHub. The v4 upload-artifact and download-artifact actions rely on the GitHub API, which Gitea doesn't have, so v3 is what actually works. This is the general pattern: don't check the action itself — check whether it talks to the platform.

6.3 How closed is the loop, really

Now for the place where "not a single byte leaves the server" stops being accurate. Code, history, secrets, and logs genuinely stay with you. But the line uses: actions/checkout@v4 pulls that action from github.com by default, and without access there, the job never starts.

Three working solutions:

  • A mirror of actions inside Gitea. Create an organization, say actions, and copy the action repositories you need into it. In app.ini, set DEFAULT_ACTIONS_URL = self, after which uses looks for actions on your own instance.
  • An explicit source in the workflow. The source is spelled out directly in uses, for cases where some actions are local and some aren't.
  • Dropping uses altogether. Plain git clone instead of actions/checkout, the right container image instead of setup-node. More verbose, but with zero external dependencies.

The first option is more convenient day to day; the third is the simplest to verify for compliance. A mirror of actions needs updating, so it needs watching too.

⚠️ Runner on the same server as Gitea. The setup is economical, but jobs run right next to the repository database. In host mode that's direct access to Gitea's files; in docker mode, a lot depends on the container's configuration. As long as the code in the repositories is trusted, the risk is acceptable. The moment external contributors or pull requests from forks show up, the runner moves to a separate machine.

7. Conclusion

7.1 Gitea Actions and GitHub Actions

The syntax is the same; the difference is where the code lives and who's responsible for keeping the system running. Practical differences worth remembering:

  • Labels describe the execution mode. Not just an agent name, but a bundle: "name : docker or host : image."
  • Not every action works. Anything that calls the GitHub API fails on Gitea, so action versions get picked to fit the platform.
  • Images are lighter. Tools that come by default in the cloud need to be installed separately here.
  • Actions pull from outside by default. For a genuinely closed loop, you need a mirror or you need to drop uses.

7.2 When to choose Gitea

Gitea makes sense when your code can't leave your own infrastructure: contractual requirements, an isolated environment, full control over the data. The second scenario is cost: your own VPS is cheaper than paying for a few dozen seats. The third is independence from a vendor's decisions.

What argues against it is the same thing that argues for it: you administer all of it. Backups, updates, uptime, HTTPS certificates, the actions mirror. If no one on the team will handle this systematically, a cloud platform with a self-hosted runner from part three or four will give you a better result for less effort.

7.3 What's next in the series

We now have three working setups, all built by hand: GitHub Actions with our own agent, GitLab CI with our own runner, and a fully local loop on Gitea. In the sixth part we'll put them all in one table and walk through how to choose based on concrete conditions: team size, code-storage requirements, pipeline complexity, budget, and how much time the team is actually willing to spend on administration.

📚 Series navigation:
You're reading part 5 of 6, "Gitea Actions: the Git platform and the pipeline on one server."
Previous: ← Part 4. GitLab CI self-hosted runner: installation and the first pipeline on a VPS
Next: Part 6. GitHub Actions vs GitLab CI vs Gitea Actions in 2026: what to choose for self-hosted CI/CD →

🚀 A server for your own Git and CI/CD loop

Gitea and the act runner on one machine give you the Git platform, the job queue, and the builds all in one place. Hostiserver provides predictable resources for this, a private network to production, and room for repository backups.

🖥️ Dedicated Servers

  • From $90/mo, full control over the hardware for the Git platform and parallel builds
  • NVMe storage: a fast Gitea interface and a cache for Docker layers
  • No build-minute limits: you pay for the server, not a quota from a third-party platform
  • Private network between the runner and production servers
  • 24/7 support: engineers help with rollout and backups

💻 Cloud (VPS) Hosting

  • From $19.95/mo, KVM isolation, dedicated vCPU and RAM
  • Perfect for Gitea: the platform starts up on 1 GB, comfortable with a runner on 4 GB
  • Easy to scale: move the act runner to a separate VPS once builds start getting in the way

💬 Not sure which option you need?
💬 Reach out and we'll help you figure it out!

Frequently Asked Questions

Do GitHub Actions workflows really run on Gitea without edits?

Simple configs carry over as-is: on, jobs, steps, run, matrices, caching, and basic actions all work the same way. Three things need adjusting. First is runs-on, since that's now your label, not a cloud machine name. Second is actions that call the GitHub API: the classic example is v4 upload-artifact and download-artifact, replaced with v3. Third is the toolset baked into the image: GitHub's cloud images come with a lot more preinstalled, so anything you need gets added as its own step.

How much resource does Gitea with a runner need?

Gitea itself, running on SQLite with a small team, works fine on 1 vCPU and 1 GB RAM. The main consumption comes from builds, not the platform: for both components to run comfortably on one box, go with 2 vCPU and 4 GB RAM; for Docker builds, 4 vCPU and 8 GB. Plan disk with margin: repositories, artifacts, images, and Docker layers grow faster than expected, so 40 GB is the minimum starting point.

Gitea or Forgejo?

Technically these are very close systems with a shared origin, and Actions are compatible on both. The difference is in governance: Gitea is developed by a company, Forgejo is a community project under the GPL license. If independence from a commercial entity matters to you, go with Forgejo. If official support and a faster release cadence matter more, go with Gitea. Everything in this article works either way — only the binary and service names change.

Can Gitea Actions work with no internet access at all?

Yes, but it needs separate setup. By default, the uses step pulls the action from github.com, so it will fail in an isolated environment. Three fixes: mirror the actions you need into your own Gitea organization and set DEFAULT_ACTIONS_URL = self, specify the full source address directly in uses, or drop actions in favor of plain commands. Separately, take care of container images too — those need to live locally, in your own registry.

Should the runner go on the same server as Gitea?

For a small team with trusted code, yes — it's economical and it works. Two reasons to split them across machines. First is resources: a heavy build eats CPU and disk, and right then the Git interface starts lagging for everyone. Second is security: the job runs right next to the repository database, and in host mode it has direct access to Gitea's files. The moment external contributors show up in the project, the runner moves to its own server.

How do you back up Gitea, and what exactly should be saved?

The gitea dump command bundles repositories, the database, configuration, attachments, and avatars into a single archive. For Docker that's docker exec -u git gitea gitea dump -c /data/gitea/conf/app.ini. The archive must live off the server: in a closed loop, that's your only copy of the code. Keep app.ini separately too, since it holds the encryption keys — without them the secrets in the database can't be restored. And test the restore on a spare machine at least once: a backup nobody has ever deployed is an assumption, not a backup.

What do you do when a job hangs in the Waiting state?

Almost always a label issue: runs-on specifies a value no online runner has. Open Settings → Actions → Runners and check the list. Next, check the agent's status with systemctl status act_runner and its logs with journalctl -u act_runner -f. Other common causes: Actions disabled for that specific repository under Advanced Settings, an instance address the runner can't reach, or the user the service runs as lacking permissions on the Docker socket.

Contents

Share this article

MANAGED VPS STARTING AT

$19 95 / mo

NEW INTEL XEON BASED SERVERS

$80 / mo

CDN STARTING AT

$0 / mo

 

By using this website you consent to the use of cookies in accordance with our privacy and cookie policy.