← All articles
AUTOMATION Self-Hosted CI/CD with Gitea Actions 2026-02-09 · ci-cd · gitea · automation

Self-Hosted CI/CD with Gitea Actions

Automation 2026-02-09 ci-cd gitea automation devops

If you're self-hosting your Git repositories with Gitea, you're probably tired of reaching for an external CI/CD service every time you want to automate a build or deployment. Gitea Actions brings GitHub Actions-compatible CI/CD directly into your Gitea instance — same workflow syntax, same runner model, but running entirely on your own hardware.

This guide walks through setting up Gitea Actions from scratch: installing Gitea, enabling Actions, configuring runners, writing workflows, building Docker images, and deploying to your homelab infrastructure. By the end, you'll have a fully self-hosted pipeline where pushing code triggers builds and deployments without ever touching a third-party service.

Why Gitea Actions Over Other CI/CD Tools

Before diving in, let's acknowledge the alternatives and when they make more sense.

Jenkins is the enterprise workhorse. It can do anything, but configuring it feels like filing taxes. The UI is dated, the plugin ecosystem is sprawling and inconsistent, and simple pipelines require more YAML and Groovy than you'd expect. If you already know Jenkins or need its specific integrations, it's fine. For a homelab, it's overkill.

Drone CI was the darling of self-hosted CI/CD for years. Lightweight, Docker-native, clean UI. But Drone's development has slowed significantly since the Harness acquisition, and the licensing became complicated. Gitea Actions is effectively Drone's spiritual successor for the Gitea ecosystem.

Woodpecker CI is a community fork of Drone that remains actively developed and works well with Gitea. It's a solid choice if you want something battle-tested with its own identity rather than GitHub Actions compatibility.

Gitea Actions wins for homelabs because:

The trade-off: Gitea Actions is younger than the alternatives. Not every GitHub Action is compatible (anything that calls the GitHub API won't work), and some edge cases in workflow syntax aren't supported yet. For typical build-test-deploy pipelines, it works well.

Installing Gitea

If you don't already have Gitea running, here's a quick Docker Compose setup:

# ~/docker/gitea/docker-compose.yml
services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    restart: unless-stopped
    ports:
      - "3000:3000"
      - "2222:22"
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=sqlite3
      - GITEA__server__ROOT_URL=http://gitea.lab.example.com:3000
      - GITEA__server__SSH_DOMAIN=gitea.lab.example.com
      - GITEA__server__SSH_PORT=2222
    volumes:
      - ./data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
docker compose up -d

Visit http://your-server:3000 to complete initial setup. Create an admin account when prompted.

For production use, swap SQLite for PostgreSQL — SQLite is fine for small teams but becomes a bottleneck under concurrent access:

  gitea-db:
    image: postgres:16
    container_name: gitea-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: gitea
      POSTGRES_USER: gitea
      POSTGRES_PASSWORD: change-this-password
    volumes:
      - gitea_pgdata:/var/lib/postgresql/data

volumes:
  gitea_pgdata:

Then set GITEA__database__DB_TYPE=postgres, GITEA__database__HOST=gitea-db:5432, and the corresponding credentials.

Enabling Gitea Actions

Actions is built into Gitea but disabled by default. Enable it in Gitea's configuration:

# In the Gitea config (data/gitea/conf/app.ini for Docker installs)
[actions]
ENABLED = true

Restart Gitea after making this change:

docker compose restart gitea

Once enabled, you'll see an "Actions" tab in repository settings and a new "Runners" section in the site administration panel.

Enable Actions Per-Repository

Actions must be enabled for each repository individually:

  1. Go to the repository's Settings > General
  2. Scroll to Advanced Settings
  3. Check Enable Repository Actions
  4. Save

You can also enable Actions by default for all new repositories in the site admin settings.

Setting Up the Gitea Actions Runner

The runner is a separate process that picks up jobs from Gitea and executes them. It's called act_runner and can run on the same machine as Gitea or on dedicated build machines.

Install act_runner

# Download the latest release
wget https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
chmod +x act_runner-0.2.11-linux-amd64
sudo mv act_runner-0.2.11-linux-amd64 /usr/local/bin/act_runner

Register the Runner

First, get a registration token from Gitea:

  1. Go to Site Administration > Runners
  2. Click Create new Runner
  3. Copy the registration token

Then register the runner:

# Generate a default config
act_runner generate-config > config.yaml

# Register with your Gitea instance
act_runner register \
  --instance http://gitea.lab.example.com:3000 \
  --token YOUR_REGISTRATION_TOKEN \
  --name homelab-runner \
  --labels ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://ubuntu:22.04,self-hosted:host

The --labels flag is important. It maps workflow runs-on labels to execution environments:

Configure the Runner

Edit config.yaml to tune the runner for your environment:

# config.yaml
runner:
  # How many jobs can run in parallel
  capacity: 4
  # Timeout for a single job
  timeout: 3h
  # Labels (set during registration, but can be updated here)
  labels:
    - "ubuntu-latest:docker://node:20-bookworm"
    - "ubuntu-22.04:docker://ubuntu:22.04"
    - "self-hosted:host"

container:
  # Network mode for Docker containers
  network: "bridge"
  # Privileged mode (needed for Docker-in-Docker)
  privileged: false
  # Mount the Docker socket (needed for building images)
  options: "-v /var/run/docker.sock:/var/run/docker.sock"
  # Workdir inside the container
  workdir_parent: /workspace

Run as a Systemd Service

Create /etc/systemd/system/act_runner.service:

[Unit]
Description=Gitea Actions Runner
After=network.target docker.service

[Service]
Type=simple
User=runner
Group=docker
WorkingDirectory=/opt/act_runner
ExecStart=/usr/local/bin/act_runner daemon -c /opt/act_runner/config.yaml
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
# Create the runner user and directory
sudo useradd -r -s /bin/false runner
sudo mkdir -p /opt/act_runner
sudo cp config.yaml .runner /opt/act_runner/
sudo chown -R runner:docker /opt/act_runner

# Add runner to docker group (for building images)
sudo usermod -aG docker runner

# Start the service
sudo systemctl daemon-reload
sudo systemctl enable --now act_runner

Check that the runner appears as online in Gitea's admin panel under Runners.

Writing Your First Workflow

Gitea Actions uses the same workflow syntax as GitHub Actions. Workflow files go in .gitea/workflows/ (not .github/workflows/, though Gitea can read from .github/workflows/ if you enable the compatibility setting).

Basic Build and Test

# .gitea/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run linter
        run: npm run lint

This is identical to what you'd write for GitHub Actions. The actions/checkout@v4 and actions/setup-node@v4 actions are fetched from GitHub by default. You can configure Gitea to use a mirror or your own action repositories if you want to stay fully self-hosted.

Using Gitea-Native Actions

To avoid depending on GitHub, Gitea provides its own versions of common actions:

steps:
  - name: Checkout
    uses: https://gitea.com/actions/checkout@v4

  - name: Setup Go
    uses: https://gitea.com/actions/setup-go@v5
    with:
      go-version: '1.22'

You can also host actions in your own Gitea repositories and reference them by their full URL.

Building Docker Images

One of the most common CI/CD tasks is building and pushing Docker images. Here's how to do it with Gitea Actions.

Building with Docker Socket

If you mounted the Docker socket in your runner config (the container.options setting above), containers can talk to the host's Docker daemon:

# .gitea/workflows/docker-build.yml
name: Build Docker Image

on:
  push:
    branches: [main]
    tags: ['v*']

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

      - name: Build image
        run: |
          docker build -t myapp:${{ gitea.sha }} .
          docker tag myapp:${{ gitea.sha }} myapp:latest

      - name: Push to local registry
        run: |
          docker tag myapp:latest registry.lab.example.com/myapp:latest
          docker tag myapp:${{ gitea.sha }} registry.lab.example.com/myapp:${{ gitea.sha }}
          docker push registry.lab.example.com/myapp:latest
          docker push registry.lab.example.com/myapp:${{ gitea.sha }}

Setting Up a Local Docker Registry

A private registry keeps your images on your own network:

# ~/docker/registry/docker-compose.yml
services:
  registry:
    image: registry:2
    container_name: registry
    restart: unless-stopped
    ports:
      - "5000:5000"
    volumes:
      - ./data:/var/lib/registry
    environment:
      REGISTRY_STORAGE_DELETE_ENABLED: "true"

If using an insecure (HTTP) registry, add it to Docker's daemon config on every machine that needs to pull from it:

// /etc/docker/daemon.json
{
  "insecure-registries": ["registry.lab.example.com:5000"]
}

Restart Docker after editing daemon.json: sudo systemctl restart docker.

Deploying to Your Homelab

The real payoff of self-hosted CI/CD is automated deployments. Here are three common patterns.

Pattern 1: SSH Deploy

The simplest approach — SSH into the target machine and run deployment commands:

# .gitea/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: self-hosted
    steps:
      - name: Deploy via SSH
        run: |
          ssh deploy@app-server.lab.example.com << 'EOF'
            cd /opt/myapp
            docker compose pull
            docker compose up -d
            docker image prune -f
          EOF

This requires SSH key access from the runner to the target. Set up a dedicated deploy user with limited permissions on the target machine.

Pattern 2: Docker Compose with Registry

Build the image, push to your local registry, then tell the target machine to pull and restart:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build and push
        run: |
          docker build -t registry.lab.example.com:5000/myapp:latest .
          docker push registry.lab.example.com:5000/myapp:latest

  deploy:
    needs: build
    runs-on: self-hosted
    steps:
      - name: Pull and restart
        run: |
          ssh deploy@app-server << 'EOF'
            docker pull registry.lab.example.com:5000/myapp:latest
            cd /opt/myapp
            docker compose up -d
          EOF

Pattern 3: Webhook Deploy

Instead of SSH, trigger a webhook on the target machine. Tools like webhook listen for HTTP requests and run scripts:

      - name: Trigger deploy webhook
        run: |
          curl -X POST \
            -H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
            http://app-server.lab.example.com:9000/hooks/deploy-myapp

This avoids needing SSH access from the runner and keeps the deployment logic on the target machine.

Working with Secrets

Store sensitive values (SSH keys, registry credentials, API tokens) as repository or organization secrets:

  1. Go to the repository's Settings > Actions > Secrets
  2. Add a secret (e.g., DEPLOY_SSH_KEY, REGISTRY_PASSWORD)
  3. Reference them in workflows:
env:
  REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}

steps:
  - name: Login to registry
    run: echo "$REGISTRY_PASSWORD" | docker login registry.lab.example.com -u deploy --password-stdin

Secrets are masked in logs automatically, just like GitHub Actions.

Multi-Architecture Builds

If your homelab includes ARM machines (Raspberry Pis, ARM servers), you can build multi-arch images:

      - name: Set up QEMU
        run: docker run --privileged --rm tonistiigi/binfmt --install all

      - name: Build multi-arch image
        run: |
          docker buildx create --use
          docker buildx build \
            --platform linux/amd64,linux/arm64 \
            -t registry.lab.example.com:5000/myapp:latest \
            --push .

Caching Dependencies

Speed up builds by caching dependencies between runs. Gitea Actions supports the same caching mechanism as GitHub Actions:

      - name: Cache node modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install dependencies
        run: npm ci

Monitoring Your CI/CD Pipeline

Runner Health

Check runner status in Gitea's admin panel. If a runner goes offline, Gitea will show it as inactive. Set up a simple health check:

# Check if act_runner process is running
systemctl is-active act_runner

# Check runner logs
journalctl -u act_runner -f --no-pager -n 50

Build Notifications

Add a notification step to your workflows for visibility:

      - name: Notify on failure
        if: failure()
        run: |
          curl -H "Content-Type: application/json" \
            -d '{"content": "Build failed: ${{ gitea.repository }}@${{ gitea.sha }}"}' \
            ${{ secrets.DISCORD_WEBHOOK }}

Gotchas and Tips

GitHub Actions compatibility is not 100%: Actions that use the GitHub API (github.rest, octokit) won't work. Actions that are pure Docker or JavaScript usually work fine. Test before relying on them.

Container labels matter: If your workflow uses runs-on: ubuntu-latest but your runner doesn't have that label mapped, the job will hang in "waiting" state forever. Double-check your runner labels.

Docker socket security: Mounting the Docker socket gives containers root-equivalent access to the host. This is fine in a trusted homelab. For anything more, look into rootless Docker or Sysbox.

Disk space: CI builds generate a lot of temporary files and Docker layers. Set up periodic cleanup:

# Cron job to clean up Docker resources
0 3 * * * docker system prune -af --volumes --filter "until=72h"

Start simple: Your first workflow should be a basic build-and-test. Add deployment automation once you trust the build pipeline. Add caching and multi-arch builds once the basics work. Incremental complexity beats a complex setup that doesn't work.

Gitea Actions gives you a GitHub Actions-compatible CI/CD system running entirely on your own hardware. For a homelab, that means your code, your builds, and your deployments all stay under your control — and you learn how the whole pipeline works by setting it up yourself.