Drone vs Woodpecker vs Gitea Actions: Which CI Actually Belongs on Your Self-Hosted Forge?

You set up Gitea or Forgejo, migrated your repos, killed your GitHub dependency — and then hit the wall. You still need CI. And suddenly you’re staring at three different options that all look reasonable on the surface but behave very differently under load.

Drone, Woodpecker, and Gitea Actions each have a distinct history and a distinct set of tradeoffs. Pick the wrong one and you’ll spend a weekend ripping it out. This article is the comparison I wish I had before making that mistake myself.


The Landscape in 2024 (and Why It’s Complicated)

Drone started as the darling of the Docker-native CI world. Clean YAML, containers for everything, dead-simple to self-host. Then Harness acquired it in 2020 and the "community edition" went stale. The repo still exists, the docs are still up, but development happens behind a paywall now.

Woodpecker is a fork of Drone 0.8 — the last version before the Harness pivot. The community took it, stripped the proprietary bits, and kept building. It’s Apache 2.0, actively maintained, and compatible with most Drone pipelines. If you liked old Drone, Woodpecker is where that lineage lives.

Gitea Actions landed in Gitea 1.19 and is basically a GitHub Actions runtime baked into the forge itself. Same YAML syntax, same on: triggers, same uses: references. If your team already knows GitHub Actions, the learning curve is nearly zero.

These three options are not equally positioned. Drone is effectively legacy for new installs. The real choice is Woodpecker vs Gitea Actions, with the decision axis being: how much do you want CI decoupled from the forge?


Drone CI: Still Standing, But Barely

GitHub: https://github.com/harness/drone

Drone’s architecture is clean: a server process connects to your SCM via OAuth, and agents (called runners) pick up pipeline jobs. Runners are Docker-based by default, so each step runs in its own container — full isolation, reproducible environments.

A minimal Drone pipeline looks like this:

# .drone.yml
kind: pipeline
type: docker
name: default

steps:
  - name: test
    image: golang:1.22-alpine
    commands:
      - go mod download
      - go test ./...

  - name: build
    image: golang:1.22-alpine
    commands:
      - CGO_ENABLED=0 go build -o ./bin/app ./cmd/app
    when:
      branch: main

The syntax is clean and readable. Conditions are first-class. Multi-arch builds, parallelism, volumes, caching — it’s all there.

The problem is the server binary. The community edition hasn’t received a meaningful feature update since 2022. Security patches are sporadic. The Docker Hub images lag. You can still run it, but you’re essentially running abandoned software and hoping nothing breaks hard enough to matter.

Gotcha: The License Trap

Drone CE is free for teams under a certain size (historically capped at a few hundred users, but the terms have shifted). If you run an organization that grows, you might find yourself in a licensing grey area. Woodpecker has no such restriction — it’s fully open source.

Verdict on Drone: Don’t start new installs on Drone. If you’re already running it and it works, fine. Otherwise, migrate to Woodpecker — the pipeline syntax is almost identical.


Woodpecker CI: The Right Fork at the Right Time

GitHub: https://github.com/woodpecker-ci/woodpecker

Woodpecker is what Drone should have become. Active maintainers, real releases, proper changelogs, and a community that actually shows up on the issue tracker. It supports Gitea, Forgejo, GitHub, GitLab, Bitbucket, and more as SCM backends.

Setup with Docker Compose

Here’s a production-grade Woodpecker stack for Gitea:

# docker-compose.yml
version: "3.8"

services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:latest
    restart: unless-stopped
    ports:
      - "8000:8000"   # UI and API
      - "9000:9000"   # gRPC for agent communication
    environment:
      - WOODPECKER_OPEN=false                          # require oauth, no public signups
      - WOODPECKER_HOST=https://ci.example.com
      - WOODPECKER_GITEA=true
      - WOODPECKER_GITEA_URL=https://git.example.com
      - WOODPECKER_GITEA_CLIENT=${GITEA_OAUTH_CLIENT}
      - WOODPECKER_GITEA_SECRET=${GITEA_OAUTH_SECRET}
      - WOODPECKER_AGENT_SECRET=${AGENT_SECRET}       # shared secret for agents
      - WOODPECKER_DATABASE_DRIVER=postgres
      - WOODPECKER_DATABASE_DATASOURCE=postgres://woodpecker:${DB_PASS}@db/woodpecker?sslmode=disable
      - WOODPECKER_ADMIN=your-gitea-username
    volumes:
      - woodpecker-server-data:/var/lib/woodpecker/
    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     # concurrent pipelines per agent
      - WOODPECKER_BACKEND=docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - woodpecker-server

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=woodpecker
      - POSTGRES_PASSWORD=${DB_PASS}
      - POSTGRES_DB=woodpecker
    volumes:
      - woodpecker-db:/var/lib/postgresql/data

volumes:
  woodpecker-server-data:
  woodpecker-db:

The agent talks to the server over gRPC. You can run multiple agents on different hosts — handy when you need ARM runners or GPU nodes alongside your x86 fleet.

Pipeline Syntax

Woodpecker is nearly syntax-compatible with Drone, with some improvements:

# .woodpecker.yml
when:
  - event: push
    branch: main
  - event: pull_request

steps:
  test:
    image: node:20-alpine
    commands:
      - npm ci
      - npm test

  lint:
    image: node:20-alpine
    commands:
      - npm run lint
    # runs in parallel with the 'test' step
    depends_on: []

  docker-build:
    image: woodpeckerci/plugin-docker-buildx
    settings:
      repo: registry.example.com/myapp
      registry: registry.example.com
      username:
        from_secret: registry_user
      password:
        from_secret: registry_password
      tags:
        - latest
        - ${CI_COMMIT_SHA:0:8}
    when:
      branch: main

Steps run sequentially by default but support explicit parallelism via depends_on. The from_secret pattern is clean and keeps credentials out of your repo.

Gotcha: Agent Secret Rotation

The WOODPECKER_AGENT_SECRET is used for all server-agent communication. Rotating it means restarting every agent simultaneously or accepting a brief window where old agents can’t connect. There’s no rolling rotation mechanism. Plan for a maintenance window if you ever need to change it — which you will if an agent VM gets compromised.

Gotcha: Docker Socket Exposure

The default agent config mounts /var/run/docker.sock — which is effectively root access to the host. For multi-tenant setups, consider running agents in dedicated VMs or using the WOODPECKER_BACKEND=local backend combined with proper user isolation. Woodpecker also has an experimental Kubernetes backend if you want stronger isolation.

Production Note: Separate Your Agent VMs

Don’t run the Woodpecker agent on the same host as your server or your Gitea instance. Build workloads are noisy — they pull large images, chew through disk in /var/lib/docker, and occasionally spike CPU. Isolation is cheap insurance.


Gitea Actions: The Built-In Option

Gitea Actions is different in a fundamental way: there’s nothing to install separately. Enable it in your app.ini, deploy one or more act runners, and you’re done. The pipeline lives in .gitea/workflows/ and uses GitHub Actions syntax verbatim.

Enabling in Gitea

# app.ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = https://github.com  # where to resolve actions/checkout@v4 etc.

Deploying the Act Runner

# docker-compose.yml (runner only)
version: "3.8"

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
      - GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bullseye
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - act-runner-data:/data

volumes:
  act-runner-data:

Get the registration token from Gitea’s admin panel (/-/admin/runners) or at the organization/repo level. After registration, the token isn’t needed again — runner state is stored locally.

Pipeline Syntax

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

on:
  push:
    branches: [main]
  pull_request:

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

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: "1.22"

      - name: Run tests
        run: go test ./...

  build-and-push:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Login to registry
        uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: registry.example.com/myapp:${{ github.sha }}

The uses: directive pulls actions from GitHub by default. You can change DEFAULT_ACTIONS_URL to point at your own Gitea instance if you want fully air-gapped operation — just mirror the actions repos you need.

Gotcha: DEFAULT_ACTIONS_URL and Air-Gapped Setups

If your Gitea server doesn’t have internet access, pipelines will hang or fail the moment they hit uses: actions/checkout@v4. The fix is mirroring critical actions into your Gitea instance and pointing DEFAULT_ACTIONS_URL at it. This is annoying to set up initially but manageable. Don’t forget actions/checkout, actions/setup-go, actions/setup-node, docker/login-action, and docker/build-push-action — those cover 90% of common pipelines.

Gotcha: Runner Label Mapping

Labels like ubuntu-latest don’t correspond to actual Ubuntu images automatically. You define the mapping in the runner’s config file or via environment variables. If you forget this, pipelines that specify runs-on: ubuntu-latest will queue forever because no runner claims them. Check ~/.config/act-runner/config.yaml on the runner host and explicitly define your label-to-image mappings.

Gotcha: Reusable Workflows and Composite Actions

Gitea Actions doesn’t implement all of GitHub Actions’ feature surface. Reusable workflows (workflow_call), composite actions, and environment protection rules are partially or not yet implemented depending on your Gitea version. Check the compatibility matrix before committing to patterns that rely on these features.


Head-to-Head: The Honest Comparison

Drone CE Woodpecker Gitea Actions
Maintenance status Effectively abandoned Actively developed Actively developed
License Apache 2.0 (CE) Apache 2.0 MIT (Gitea)
Pipeline syntax Drone YAML Drone YAML (extended) GitHub Actions YAML
Separate install Yes Yes No (built-in)
Multi-host runners Yes Yes Yes
Secrets management Built-in Built-in Built-in
Plugin ecosystem Large (Docker Hub) Compatible with Drone plugins + own GitHub Actions marketplace
Matrix builds Basic Yes Yes
Caching Volumes only Volumes + plugins actions/cache@v3 compatible
Forgejo support No Yes Via Forgejo Actions
Learning curve Low Low Low (if you know GHA)
Air-gapped friendly Yes Yes Needs mirror setup

When to Pick What

Pick Woodpecker if:

  • You’re running Forgejo (Gitea’s hard fork) — Gitea Actions only works with Gitea proper; Forgejo has its own Actions implementation but Woodpecker is a cleaner choice there
  • You want CI completely decoupled from your forge — Woodpecker runs fine against GitHub or GitLab too, so you’re not locked to one SCM
  • Your team comes from a Drone background and you don’t want to rewrite pipelines
  • You need simple, fast pipeline execution without the overhead of GitHub Actions’ job abstraction
  • You value a smaller attack surface — Woodpecker’s agent model is simpler to reason about than the act runner

Pick Gitea Actions if:

  • Your team already writes GitHub Actions and you want zero retraining
  • You’re running vanilla Gitea (not Forgejo) and want fewer moving parts
  • You need the GitHub Actions plugin ecosystem — docker/build-push-action, actions/setup-*, the whole zoo
  • You have a pipeline that needs matrix strategies or complex conditional logic, where GitHub Actions syntax genuinely shines

Don’t pick Drone for new installs. There’s no scenario where starting fresh on Drone CE makes sense when Woodpecker exists.


Production Checklist (Applies to All Three)

Resource limits on runners. Build containers can eat memory fast — a single bad npm install in a loop has crashed more than one CI host. Set Docker memory limits on your runner containers and enforce them.

Separate runner hosts from your forge. This is non-negotiable. If a build goes haywire and fills /var/lib/docker, you don’t want it taking down Gitea with it.

Secret scoping. Both Woodpecker and Gitea Actions let you scope secrets to specific repos or organizations. Don’t create global secrets unless you actually need them globally — least privilege applies here too.

Runner auto-registration with limits. Gitea’s act runner supports registration tokens with expiry. Use them. If a runner token leaks, an attacker can register their own runner and intercept pipeline secrets.

Backup your CI database. Woodpecker’s Postgres (or SQLite) stores your pipeline history, secrets, and repo activation state. Losing it means re-activating every repo and re-entering every secret. Back it up daily.


My Take

After running all three in various configurations, Woodpecker is my default recommendation for most self-hosted setups. It’s stable, the pipeline syntax is clean and explicit, the codebase is manageable, and you’re not dependent on GitHub’s ecosystem.

Gitea Actions wins when the team is GitHub-native and the integration benefit outweighs the operational complexity of managing the act runner plus the DEFAULT_ACTIONS_URL dance for air-gapped environments.

Drone is a museum piece at this point. Pay your respects and move on.

The deeper point: CI should be boring infrastructure. You want to set it up once, forget about it, and have it reliably build and test your code for years. Both Woodpecker and Gitea Actions can do that. Pick based on your team’s existing workflow, not on theoretical feature comparisons.

Leave a comment

👁 Views: 2,290 · Unique visitors: 1,647