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:
- Gitea Actions: CI as a forge feature. Tight coupling, low overhead, familiar syntax.
- Woodpecker CI: CI as an independent service. Loose coupling, higher overhead, purpose-built pipeline engine.
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):
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: latestNotice 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):
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:
[actions]ENABLED = trueThen run act_runner. Here’s a minimal Docker Compose setup:
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:
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
| Feature | Gitea Actions | Woodpecker CI |
|---|---|---|
| Setup complexity | Low (enable flag + act_runner) | Medium (server + agent + DB + OAuth) |
| Pipeline syntax | GitHub Actions workflow YAML | Woodpecker pipeline YAML |
| Runner model | act_runner (stateless, Gitea-managed) | Server + independent agents |
| GitHub Actions compat | High (actions/checkout, marketplace plugins) | None natively |
| Plugin/action ecosystem | GitHub marketplace (via mirror) | Woodpecker plugin hub (smaller) |
| Resource footprint | Very low (runner only) | Medium (server + DB + agent) |
| Secrets handling | Gitea-managed, per-repo/org | Woodpecker-managed, per-repo/org/global |
| Matrix builds | Yes (GitHub Actions matrix strategy) | Yes (Woodpecker matrix:) |
| Step isolation | Per-job container | Per-step container |
| Multi-forge support | Gitea/Forgejo only | Gitea, Forgejo, GitHub, GitLab, Bitbucket |
| Web UI | Inside Gitea | Separate Woodpecker UI |
| Maturity | Newer (Gitea 1.19+, 2023) | Older (Drone fork, 2019) |
| Caching | GitHub-compatible cache actions | Plugin-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:
steps: - name: Deploy env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} API_TOKEN: ${{ secrets.API_TOKEN }} run: ./scripts/deploy.shIn Woodpecker, secrets are managed in Woodpecker’s own UI (or via its API). Reference them with Woodpecker’s syntax:
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:
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:
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:
[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 = githubIf 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:
- You’re migrating from GitHub Actions and want to reuse existing workflow files with minimal changes.
- Resource footprint matters — you’re running on constrained hardware or want to keep your stack minimal.
- Your forge is Gitea/Forgejo only — no plans to run CI against multiple forges.
- You want CI baked into your forge UI — one less application to maintain, monitor, and upgrade.
- Your team is already fluent in GitHub Actions YAML — zero onboarding cost.
- You don’t need per-step container isolation — Gitea Actions runs all steps in one job container (or the runner’s host Docker) rather than spinning a fresh container per step.
When to Pick Woodpecker CI
Pick Woodpecker CI when:
- You run multiple forges — Woodpecker supports Gitea, Forgejo, GitHub, GitLab, and Bitbucket from one server.
- Per-step isolation matters — every Woodpecker step gets its own fresh container, reducing state bleed between steps.
- You want CI independent of your forge — if Gitea goes down for maintenance, Woodpecker keeps queued builds, logs, and history intact in its own database.
- Fine-grained secret policies are important — branch- and event-scoped secrets are native to Woodpecker.
- You have a team that doesn’t know GitHub Actions syntax — Woodpecker’s pipeline format is arguably more intuitive for people coming from Jenkins or plain Docker.
- You plan to scale horizontally — adding more Woodpecker agents is trivial. Scaling act_runner is also possible, but Woodpecker’s agent model was designed for it from the start.
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.