Skip to content
Go back

Gitea Actions vs Woodpecker CI

By SumGuy 13 min read
Gitea Actions vs Woodpecker CI

You’ve got Gitea (or Forgejo) running. Your code lives there, your team is happy, and then someone asks the obvious question: “So… how do we run CI?” And you’ve got two solid answers sitting right in front of you — Gitea Actions, which is baked right into your forge, and Woodpecker CI, which has been quietly doing this longer than Gitea Actions existed.

Both work. Both support self-hosted runners. Both integrate with Gitea/Forgejo natively. And that’s exactly why this comparison matters — because “both work” isn’t a decision. Let me save you the weekend of trial and error.

Already read Woodpecker CI vs Drone CI, or you need the Forgejo Actions runner setup walkthrough? Those are separate articles — this one is purely about the Gitea Actions vs Woodpecker fork in the road.

Full example: Clone both ready-to-run stacks — act_runner and the full Woodpecker server/agent/Postgres setup, with matching Go pipelines — at github.com/KingPin/sumguy-examples.


The Architecture Gap Is Real

Before you look at a single line of YAML, understand what you’re actually choosing between.

Gitea Actions is built into your Gitea/Forgejo instance. You enable it in app.ini, spin up one or more act_runner processes (which phone home to Gitea over HTTP), and done — pipeline triggers, logs, and status badges all live inside Gitea’s UI. No extra server. No extra database. The runner is stateless; Gitea itself holds all the job state.

Woodpecker CI is a completely separate application with its own server process, its own agents, its own database, and its own web UI. It connects to your forge via OAuth and webhooks. Your forge stays out of the execution loop — Woodpecker handles scheduling, logs, artifacts, secrets, and the UI independently. More moving parts, but also more autonomy.

Here’s the quick mental model:

Neither is wrong. They’re just aimed at different points on the complexity curve.


The YAML Story

This is where you’ll feel the difference most on day one.

Gitea Actions — GitHub Workflow Syntax

Gitea Actions reuses GitHub Actions workflow syntax verbatim. If you’ve written a GitHub Actions pipeline in the last three years, you already know this format. Create .gitea/workflows/ci.yml (or .forgejo/workflows/ci.yml on Forgejo):

.gitea/workflows/ci.yml
name: CI
on:
push:
branches: [main, dev]
pull_request:
jobs:
build-and-test:
runs-on: ubuntu-latest
container:
image: golang:1.22-alpine
steps:
- uses: actions/checkout@v4
- name: Download dependencies
run: go mod download
- name: Run tests
run: go test ./...
- name: Build
run: go build -o bin/app ./cmd/app
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v6
with:
version: latest

Notice uses: actions/checkout@v4 — that’s pulling a real GitHub Actions action. By default, act_runner fetches actions from github.com (configurable to a mirror). This is the killer feature for teams migrating from GitHub Actions: your existing workflows often run with zero or minimal changes.

Woodpecker CI — Pipeline Syntax

Woodpecker has its own YAML format. It’s simpler in structure, but it’s a different language you have to learn. The equivalent pipeline lives at .woodpecker.yml (or split across .woodpecker/*.yml for complex projects):

.woodpecker.yml
steps:
- name: build-and-test
image: golang:1.22-alpine
commands:
- go mod download
- go test ./...
- go build -o bin/app ./cmd/app
- name: lint
image: golangci/golangci-lint:latest
commands:
- golangci-lint run ./...

Woodpecker’s philosophy: every step is a container. You pick the image, you run commands, done. No marketplace, no uses: — just Docker images. It’s refreshingly simple, and for most pipelines it covers everything you need. But the moment you want to reuse a community action from the GitHub marketplace, you’re writing it yourself.

Woodpecker does have its own plugin ecosystem — Docker image wrappers designed for Woodpecker — but it’s much smaller than the GitHub Actions marketplace.


Standing Up the Infrastructure

act_runner (Gitea Actions)

You need Gitea 1.19+ with Actions enabled in app.ini:

app.ini (excerpt)
[actions]
ENABLED = true

Then run act_runner. Here’s a minimal Docker Compose setup:

docker-compose.yml
services:
act_runner:
image: gitea/act_runner:latest
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://git.example.com
GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN}
GITEA_RUNNER_NAME: primary-runner
# Optional: point at a config.yaml for custom labels, cache, etc.
# CONFIG_FILE: /config/config.yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- act_runner_data:/data
volumes:
act_runner_data:

Register the runner token in Gitea under Site Administration → Actions → Runners (or at the repo/org level for scoped runners). That’s the entire setup. Gitea handles job queuing and log storage.

Woodpecker Server + Agent

Woodpecker needs two services and a database:

docker-compose.yml
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:latest
restart: unless-stopped
ports:
- "8000:8000"
- "9000:9000" # gRPC port for agents
environment:
WOODPECKER_OPEN: "false"
WOODPECKER_HOST: https://ci.example.com
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: https://git.example.com
WOODPECKER_GITEA_CLIENT: ${GITEA_OAUTH_CLIENT_ID}
WOODPECKER_GITEA_SECRET: ${GITEA_OAUTH_CLIENT_SECRET}
WOODPECKER_AGENT_SECRET: ${AGENT_SECRET}
WOODPECKER_DATABASE_DRIVER: postgres
WOODPECKER_DATABASE_DATASOURCE: postgres://woodpecker:${DB_PASS}@db:5432/woodpecker?sslmode=disable
depends_on:
- db
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:latest
restart: unless-stopped
environment:
WOODPECKER_SERVER: woodpecker-server:9000
WOODPECKER_AGENT_SECRET: ${AGENT_SECRET}
WOODPECKER_MAX_WORKFLOWS: 4
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- woodpecker-server
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: woodpecker
POSTGRES_USER: woodpecker
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
pg_data:

You also need to create an OAuth application in Gitea (Settings → Applications → OAuth 2 Applications), set the callback URL to https://ci.example.com/authorize, and wire in the client ID and secret. More steps, but each one is straightforward.


Comparison Table

FeatureGitea ActionsWoodpecker CI
Setup complexityLow (enable flag + act_runner)Medium (server + agent + DB + OAuth)
Pipeline syntaxGitHub Actions workflow YAMLWoodpecker pipeline YAML
Runner modelact_runner (stateless, Gitea-managed)Server + independent agents
GitHub Actions compatHigh (actions/checkout, marketplace plugins)None natively
Plugin/action ecosystemGitHub marketplace (via mirror)Woodpecker plugin hub (smaller)
Resource footprintVery low (runner only)Medium (server + DB + agent)
Secrets handlingGitea-managed, per-repo/orgWoodpecker-managed, per-repo/org/global
Matrix buildsYes (GitHub Actions matrix strategy)Yes (Woodpecker matrix:)
Step isolationPer-job containerPer-step container
Multi-forge supportGitea/Forgejo onlyGitea, Forgejo, GitHub, GitLab, Bitbucket
Web UIInside GiteaSeparate Woodpecker UI
MaturityNewer (Gitea 1.19+, 2023)Older (Drone fork, 2019)
CachingGitHub-compatible cache actionsPlugin-based or manual volume mounts

Secrets and Environment Variables

Both systems let you define secrets at repo, org, and (in some configurations) global level. The mechanics differ.

In Gitea Actions, secrets are defined in Gitea’s UI under Repository → Settings → Actions → Secrets. You reference them with the standard GitHub Actions syntax:

.gitea/workflows/deploy.yml (secrets excerpt)
steps:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
API_TOKEN: ${{ secrets.API_TOKEN }}
run: ./scripts/deploy.sh

In Woodpecker, secrets are managed in Woodpecker’s own UI (or via its API). Reference them with Woodpecker’s syntax:

.woodpecker.yml (secrets excerpt)
steps:
- name: deploy
image: alpine/ssh:latest
commands:
- ./scripts/deploy.sh
environment:
DEPLOY_KEY:
from_secret: deploy_key
API_TOKEN:
from_secret: api_token

(Older Woodpecker had a secrets: [deploy_key] shorthand that auto-injected uppercased env vars — that was removed in 2.0. Current versions use from_secret exclusively, as above.)

Woodpecker has one feature Gitea Actions lacks out of the box: secret filtering by event and branch. You can restrict a secret to only be available on push events to main — not on pull requests from forks. Gitea Actions has no equivalent granularity at the secret level (you’d handle it with if: conditions in the workflow). For repos with external contributors, this distinction matters.


Matrix Builds

Both support matrix builds. The syntax reflects each system’s heritage.

Gitea Actions uses the GitHub Actions matrix strategy — which most people already know:

.gitea/workflows/test-matrix.yml
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.21", "1.22", "1.23"]
os: [ubuntu-latest, alpine-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- run: go test ./...

Woodpecker has its own matrix: block at the pipeline level — simpler but less flexible on matrix combinations:

.woodpecker.yml (matrix)
matrix:
GO_VERSION:
- "1.21"
- "1.22"
- "1.23"
steps:
- name: test
image: golang:${GO_VERSION}-alpine
commands:
- go test ./...

Woodpecker’s matrix applies globally to all steps. GitHub Actions’ matrix is per-job, giving you finer control when different steps need different matrix dimensions.


Resource Footprint

This is the one that sneaks up on people running on modest hardware.

Gitea Actions adds essentially zero overhead to your existing Gitea install. act_runner is a single stateless binary (~30 MB memory at idle). It polls Gitea for work and executes Docker containers on demand. If you’re already running Gitea on a 2-core VPS, act_runner fits comfortably alongside it.

Woodpecker needs a persistent server process, a database (SQLite is supported for lighter installs, Postgres recommended for anything real), and at least one agent. At idle you’re looking at maybe 150-300 MB for the server plus database, before you’ve run a single pipeline. Totally fine on a dedicated homelab box — less ideal if you’re trying to keep everything on one tiny VM.

For the “I run Gitea on a $6 VPS” crowd, this is probably the deciding factor.


GitHub Actions Marketplace Compatibility

This is Gitea Actions’ superpower and Woodpecker’s honest weak spot.

By default, Gitea downloads non-fully-qualified actions from github.com. This is a server-side Gitea setting, not an act_runner one — it lives in app.ini under [actions] DEFAULT_ACTIONS_URL. Since Gitea 1.21 it accepts exactly two values: github (the default) or self:

app.ini (excerpt)
[actions]
ENABLED = true
; "github" pulls bare actions from github.com (default)
; "self" pulls only from your own Gitea instance (good for air-gapped setups)
DEFAULT_ACTIONS_URL = github

If you need a specific action from somewhere other than the default, you don’t change a config file at all — you just write an absolute URL in the workflow itself: uses: https://gitea.com/actions/checkout@v4. With the default in place, uses: actions/setup-node@v4, uses: docker/build-push-action@v6, uses: crazy-max/ghaction-docker-meta@v5 — all of these resolve against github.com and work. Not every action works perfectly (some rely on GitHub-specific runner features), but a large percentage of the ecosystem just runs. If your team already has GitHub Actions workflows, the migration path is genuinely low-friction.

Woodpecker’s plugin ecosystem is purpose-built and well-maintained within its scope — Docker, Slack, S3, email, SSH deploy — but it’s a curated set, not a marketplace. When you need something it doesn’t have, you’re pulling a Docker image and writing the commands yourself. That’s actually fine for most use cases, but it’s a different mental model.


When to Pick Gitea Actions

Pick Gitea Actions when:


When to Pick Woodpecker CI

Pick Woodpecker CI when:


Migration Considerations

If you’re moving from one to the other, the main cost is rewriting pipelines — there’s no automated converter.

GitHub Actions → Gitea Actions: Often near-zero work. Replace runs-on: ubuntu-latest if needed (act_runner uses labels, not GitHub’s hosted runner names), verify any uses: actions actually work against your runner, and you’re probably done. Check for any actions that shell out to GitHub-specific CLI tools or expect the GitHub token API.

Gitea Actions → Woodpecker: You’re rewriting YAML. Most steps translate directly — find the equivalent Woodpecker plugin or Docker image, convert run: blocks to commands:, move secrets to Woodpecker’s format. The structure is simpler; the verbosity usually goes down.

Woodpecker → Gitea Actions: Same exercise in reverse. The GitHub Actions matrix syntax is more expressive, so complex matrix builds usually translate cleanly. You gain marketplace access, lose per-step isolation.


The Honest Verdict

If you’re starting fresh on a self-hosted Gitea/Forgejo instance with no prior CI history: start with Gitea Actions. Enable it in app.ini, drop one act_runner container, and you have CI in under 20 minutes. The syntax is familiar to anyone who’s touched GitHub in the last three years, the GitHub Actions marketplace is enormous, and you’re not adding a separate server/database/UI to your stack.

If you’re running a more serious homelab operation — multiple developers, multiple forges, per-step isolation requirements, or you want CI to survive forge downtime — Woodpecker is worth the extra setup cost. It’s battle-tested, the pipeline syntax is clean, and the separation between CI engine and forge is architecturally sound.

Here’s the thing: Gitea Actions is a great answer to “I need CI without adding anything.” Woodpecker is a great answer to “I need CI that’s a proper first-class service.” Both are legitimate. Neither is over-engineered for a homelab. Pick the one that matches where your complexity actually lives, not where you imagine it might someday grow to.

Your 2 AM incident brain will thank you for not adding Woodpecker’s database to your restore checklist if all you needed was to run go test ./... on every push.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
iperf3 + nload: Network Diagnosis

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts