# From GitHub Actions to Self-Hosted CI/CD: My Woodpecker Migration Guide with Docker

> A platform engineer's comprehensive guide to migrating from GitHub Actions to self-hosted Woodpecker CI with Docker Compose. Covers setup, pipeline configuration, security best practices, scaling strategies, and an honest cost comparison.

- Author: [Azraf Al Monzim](https://monzim.com/)
- Published: 2026-02-12
- Updated: 2026-06-12
- Topic: Infrastructure
- Tags: CI/CD, Woodpecker CI, Docker, Self-Hosted, Platform Engineering, DevOps, GitHub Actions, Docker Compose, Infrastructure, Automation
- Canonical: https://monzim.com/blog/from-github-actions-to-woodpecker

---

As a platform engineer, CI/CD is the backbone of every team I support. For years, GitHub Actions served as the default — it's tightly integrated, nearly zero-config, and "just works" for most workflows. But as my infrastructure matured and the number of pipelines grew, cracks started to show: opaque billing, limited control over runners, vendor lock-in, and the uncomfortable reality that every build artifact passes through someone else's infrastructure.

This post documents my migration from GitHub Actions to **Woodpecker CI** — a lightweight, open-source, Docker-native CI/CD system — self-hosted on my own server using Docker Compose. I'll walk through the reasoning, the setup, real pipeline configurations, and an honest comparison of the tradeoffs.

![Hero image showing the Woodpecker dashboard with active pipelines](https://storage-portfolio.monzim.com/blogs/from-github-actions-to-woodpecker/woodpecker-dashboard.webp)

## Why Self-Hosted CI/CD?

### Motivations for the Switch

GitHub Actions is excellent for getting started, but at scale — or when you care deeply about infrastructure ownership — several pain points emerge:

- **Cost at scale**: GitHub-hosted runners bill per minute. For teams running dozens of pipelines daily, costs escalate quickly, especially for ARM builds or jobs requiring custom toolchains.
- **Vendor lock-in**: Workflows written in GitHub Actions syntax are not portable. Moving to GitLab, Gitea, or any other Git host means rewriting every pipeline.
- **Limited runner control**: GitHub-hosted runners are ephemeral VMs with fixed specs. You cannot mount volumes, access local services, or persist caches across jobs without workarounds.
- **Data sovereignty**: Every build runs on Microsoft/GitHub infrastructure. For sensitive workloads — proprietary code, secrets injection, artifact signing — this is a hard constraint for many organizations.
- **Network constraints**: Need to access internal services during CI? GitHub-hosted runners require tunneling or exposing internal endpoints to the public internet.

### Evaluating Alternatives

Self-hosted CI/CD is not a new idea. The ecosystem is rich, and choosing the right tool requires weighing several factors:

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>License</th>
      <th>Docker-Native</th>
      <th>Complexity</th>
      <th>GitHub Integration</th>
      <th>Active Development</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>
        <strong>Jenkins</strong>
      </td>
      <td>MIT</td>
      <td>Plugin-based</td>
      <td>High</td>
      <td>Plugin</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>
        <strong>GitLab CI</strong>
      </td>
      <td>MIT (Core)</td>
      <td>Yes</td>
      <td>Medium-High</td>
      <td>Limited</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>
        <strong>Drone</strong>
      </td>
      <td>Source-available</td>
      <td>Yes</td>
      <td>Low</td>
      <td>Yes</td>
      <td>Slowed</td>
    </tr>
    <tr>
      <td>
        <strong>Woodpecker</strong>
      </td>
      <td>Apache 2.0</td>
      <td>Yes</td>
      <td>Low</td>
      <td>Yes</td>
      <td>Very Active</td>
    </tr>
    <tr>
      <td>
        <strong>Concourse</strong>
      </td>
      <td>Apache 2.0</td>
      <td>Yes</td>
      <td>High</td>
      <td>Manual</td>
      <td>Moderate</td>
    </tr>
    <tr>
      <td>
        <strong>Buildkite</strong>
      </td>
      <td>Proprietary</td>
      <td>Agent-based</td>
      <td>Medium</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

Jenkins was ruled out — the maintenance overhead and plugin ecosystem are a liability for small teams. GitLab CI is excellent but couples tightly with the GitLab platform. Drone was the original frontrunner, but after Harness acquired it, community development stalled and licensing became restrictive. Woodpecker, a community fork of Drone, emerged as the clear choice.

## Choosing Woodpecker

### What is Woodpecker CI?

[Woodpecker CI](https://woodpecker-ci.org/) is an open-source continuous integration engine with a focus on simplicity. It is a community-maintained fork of Drone CI (pre-acquisition) and retains full compatibility with the Drone pipeline syntax while adding significant improvements.

### Key Features That Sold Me

- **Truly open-source**: Apache 2.0 license with no enterprise-gated features. Every feature is available to every user.
- **Docker-native pipelines**: Every pipeline step runs in an isolated Docker container. No ambient state, no "works on my machine" — if it runs in Docker, it runs in Woodpecker.
- **Multi-platform support**: Native support for `linux/amd64`, `linux/arm64`, and more. Run ARM builds on ARM hardware without emulation.
- **Lightweight footprint**: The server and agent together consume under 100MB of RAM at idle. Compare that to Jenkins or GitLab Runner.
- **Forge integration**: Native support for GitHub, Gitea, Forgejo, GitLab, and Bitbucket. Switch forges without rewriting pipelines.
- **YAML-based pipeline definitions**: Pipelines live alongside your code in `.woodpecker.yml` — version-controlled, reviewable, and portable.
- **Built-in secret management**: Secrets are encrypted at rest and injected only into explicitly authorized pipelines.
- **Matrix builds**: Fan-out across multiple configurations (Go versions, Node versions, OS variants) with a single pipeline definition.

![Woodpecker repository settings page showing forge integration with GitHub](https://storage-portfolio.monzim.com/blogs/from-github-actions-to-woodpecker/woodpecker-repository-settings-698cd15bdb3d1.webp)

## Setting Up Woodpecker on Your Server

### Prerequisites

Before we begin, ensure you have:

- A Linux server (VPS or bare metal) with Docker and Docker Compose installed
- A domain name pointing to your server (e.g., `ci.yourdomain.com`)
- A reverse proxy (Traefik, Nginx, or Caddy) for TLS termination
- A GitHub OAuth App (for authentication and repository integration)

**Minimum hardware requirements:**

<table>
  <thead>
    <tr>
      <th>Resource</th>
      <th>Minimum</th>
      <th>Recommended</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CPU</td>
      <td>1 vCPU</td>
      <td>2+ vCPU</td>
    </tr>
    <tr>
      <td>RAM</td>
      <td>512 MB</td>
      <td>2 GB</td>
    </tr>
    <tr>
      <td>Disk</td>
      <td>10 GB</td>
      <td>50 GB (for Docker image cache)</td>
    </tr>
    <tr>
      <td>Network</td>
      <td>10 Mbps</td>
      <td>100 Mbps</td>
    </tr>
  </tbody>
</table>

### Step 1: Create a GitHub OAuth Application

1. Navigate to **GitHub → Settings → Developer Settings → OAuth Apps → New OAuth App**
2. Set the **Authorization callback URL** to: `https://ci.yourdomain.com/authorize`
3. Note your **Client ID** and **Client Secret**

![GitHub OAuth App creation form with the callback URL filled in](https://storage-portfolio.monzim.com/blogs/from-github-actions-to-woodpecker/github-oauth-app-creation-698cd15ad04a9.webp)

### Step 2: Docker Compose Configuration

Create a `docker-compose.yml` for the Woodpecker stack. This deploys both the **server** (web UI + API) and the **agent** (pipeline executor):

```yaml
# docker-compose.yml
# Woodpecker CI — Self-hosted CI/CD
# Gist: https://gist.github.com/monzim/c4bbe8c0336bff097e0fc5014c09bb51

services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:latest
    container_name: woodpecker-server
    restart: unless-stopped
    ports:
      - "8000:8000"
    volumes:
      - woodpecker-server-data:/var/lib/woodpecker/
    environment:
      # Server configuration
      - WOODPECKER_HOST=https://ci.yourdomain.com
      - WOODPECKER_OPEN=true

      # GitHub OAuth integration
      - WOODPECKER_GITHUB=true
      - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}
      - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}

      # Security
      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}

      # Admin users (GitHub usernames)
      - WOODPECKER_ADMIN=monzim

      # Database (SQLite by default, PostgreSQL for production)
      # - WOODPECKER_DATABASE_DRIVER=postgres
      # - WOODPECKER_DATABASE_DATASOURCE=postgres://user:pass@db:5432/woodpecker?sslmode=disable

      # Logging
      - WOODPECKER_LOG_LEVEL=info

  woodpecker-agent:
    image: woodpeckerci/woodpecker-agent:latest
    container_name: woodpecker-agent
    restart: unless-stopped
    depends_on:
      - woodpecker-server
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - woodpecker-agent-config:/etc/woodpecker
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
      - WOODPECKER_MAX_WORKFLOWS=4
      - WOODPECKER_HEALTHCHECK=true

volumes:
  woodpecker-server-data:
  woodpecker-agent-config:
```

### Step 3: Environment Variables

Create a `.env` file alongside your Compose file:

```bash
# .env
WOODPECKER_GITHUB_CLIENT=your_github_oauth_client_id
WOODPECKER_GITHUB_SECRET=your_github_oauth_client_secret
WOODPECKER_AGENT_SECRET=$(openssl rand -hex 32)
```

> **Security note**: Never commit `.env` files to version control. Use a secrets manager or encrypted environment in production.

### Step 4: Reverse Proxy Configuration

Here's a minimal Traefik configuration using Docker labels. If you prefer Nginx or Caddy, the core requirement is proxying HTTPS traffic to port `8000`:

```yaml
# Add these labels to the woodpecker-server service
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.woodpecker.rule=Host(`ci.yourdomain.com`)"
  - "traefik.http.routers.woodpecker.entrypoints=websecure"
  - "traefik.http.routers.woodpecker.tls.certresolver=letsencrypt"
  - "traefik.http.services.woodpecker.loadbalancer.server.port=8000"
```

Alternatively, with **Nginx**:

```nginx
server {
    listen 443 ssl http2;
    server_name ci.yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/ci.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ci.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket support (for live log streaming)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
```

### Step 5: Deploy

```bash
# Start the stack
docker compose up -d

# Verify both containers are running
docker compose ps

# Check server logs
docker compose logs -f woodpecker-server

# Check agent logs
docker compose logs -f woodpecker-agent
```

![Terminal output showing both woodpecker-server and woodpecker-agent containers running successfully](https://storage-portfolio.monzim.com/blogs/from-github-actions-to-woodpecker/terminal-output-showing-both-woodpecker-server-698cd15a95c85.webp)

Once the server is running, navigate to `https://ci.yourdomain.com` and authenticate with your GitHub account. Woodpecker will request permission to access your repositories.

![Woodpecker login page and the GitHub OAuth authorization prompt](https://storage-portfolio.monzim.com/blogs/from-github-actions-to-woodpecker/woodpecker-login-page-698cd1590b53e.webp)

### Step 6: Activate Repositories

After authentication, you'll see a list of your GitHub repositories. Toggle the ones you want Woodpecker to monitor. Woodpecker will automatically install a webhook on each activated repository.

![Woodpecker repository list showing several repositories with toggles enabled](https://storage-portfolio.monzim.com/blogs/from-github-actions-to-woodpecker/woodpecker-repository-list-with-a-few-repos-698cd384a1ea8.webp)

## Building and Deploying with Docker

This is where Woodpecker shines. Every pipeline step is a Docker container, which means your CI environment is reproducible, isolated, and portable.

### Pipeline Basics

Woodpecker looks for a `.woodpecker.yml` (or `.woodpecker/` directory) in your repository root. Here's the anatomy of a pipeline:

```yaml
# .woodpecker.yml
steps:
  - name: build
    image: node:20-alpine
    commands:
      - npm ci
      - npm run build
      - npm test

  - name: lint
    image: node:20-alpine
    commands:
      - npm ci
      - npm run lint
```

Each step:

- Runs in a fresh container from the specified `image`
- Has access to the repository source code (mounted automatically)
- Shares a workspace volume with other steps in the same pipeline
- Fails the pipeline if any command returns a non-zero exit code

### Docker Build and Push Workflow

For containerized applications, the most common pipeline builds a Docker image and pushes it to a registry. Woodpecker provides a dedicated plugin for this:

```yaml
# .woodpecker.yml — Docker build and push
steps:
  - name: test
    image: golang:1.22-alpine
    commands:
      - go test ./...

  - name: build-and-push
    image: woodpeckerci/plugin-docker-buildx
    settings:
      repo: registry.yourdomain.com/myapp
      tags:
        - latest
        - "${CI_COMMIT_SHA:0:8}"
      registry: registry.yourdomain.com
      username:
        from_secret: registry_username
      password:
        from_secret: registry_password
      platforms:
        - linux/amd64
        - linux/arm64
    when:
      branch: main
      event: push
```

Key points:

- **Multi-platform builds**: The `platforms` field triggers cross-compilation via Docker Buildx. No emulation setup required — the plugin handles QEMU registration.
- **Dynamic tags**: `${CI_COMMIT_SHA:0:8}` tags the image with the short commit hash for traceability.
- **Conditional execution**: The `when` block ensures this step only runs on pushes to `main`, not on pull requests.
- **Secrets**: Registry credentials are injected from Woodpecker's encrypted secret store, never exposed in logs.

### Multi-Stage Pipeline with Deploy

Here's a production-grade pipeline that builds, tests, pushes, and deploys:

```yaml
# .woodpecker.yml — Full CI/CD pipeline

steps:
  # Step 1: Run tests
  - name: test
    image: node:20-alpine
    commands:
      - npm ci --frozen-lockfile
      - npm run test:ci
      - npm run lint

  # Step 2: Build Docker image and push to registry
  - name: build-and-push
    image: woodpeckerci/plugin-docker-buildx
    settings:
      repo: ghcr.io/monzim/myapp
      tags:
        - latest
        - "${CI_COMMIT_SHA:0:8}"
        - "${CI_COMMIT_BRANCH}"
      registry: ghcr.io
      username:
        from_secret: ghcr_username
      password:
        from_secret: ghcr_token
    when:
      branch: main
      event: push

  # Step 3: Deploy to production via SSH
  - name: deploy
    image: appleboy/drone-ssh
    settings:
      host:
        from_secret: deploy_host
      username:
        from_secret: deploy_user
      key:
        from_secret: deploy_ssh_key
      script:
        - cd /opt/myapp
        - docker compose pull
        - docker compose up -d --remove-orphans
        - docker image prune -f
    when:
      branch: main
      event: push

  # Step 4: Notify on failure
  - name: notify-failure
    image: plugins/webhook
    settings:
      urls:
        from_secret: discord_webhook
      content_type: application/json
      template: |
        {
          "content": "❌ Pipeline failed for **{{ repo.name }}** on branch `{{ build.branch }}`\nCommit: {{ build.message }}\nLink: {{ build.link }}"
        }
    when:
      status: failure
```

![Woodpecker pipeline view showing all four steps (test, build, deploy, notify) with green checkmarks](https://storage-portfolio.monzim.com/blogs/from-github-actions-to-woodpecker/Woodpecker%20pipeline%20view%20showing%20all%20four%20steps.webp)

### Matrix Builds

Need to test across multiple versions? Woodpecker supports matrix expansion:

```yaml
# .woodpecker.yml — Matrix build
matrix:
  GO_VERSION:
    - "1.21"
    - "1.22"
    - "1.23"
  DATABASE:
    - postgres
    - sqlite

steps:
  - name: test
    image: golang:${GO_VERSION}-alpine
    commands:
      - go test -v ./... -tags ${DATABASE}
```

This generates **6 pipeline runs** (3 Go versions × 2 databases), all executed in parallel if agents are available.

### Integration with Docker Compose for Local Testing

One advantage of Woodpecker's Docker-native approach is that your CI environment mirrors your local development environment. If you use Docker Compose locally, the same images run in CI:

```yaml
# .woodpecker.yml — Using services (like docker-compose)
services:
  - name: postgres
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass

  - name: redis
    image: redis:7-alpine

steps:
  - name: integration-test
    image: node:20-alpine
    environment:
      DATABASE_URL: postgres://testuser:testpass@postgres:5432/testdb
      REDIS_URL: redis://redis:6379
    commands:
      - npm ci
      - npm run test:integration
```

The `services` block spins up sidecar containers that are network-accessible by name from your pipeline steps — identical to Docker Compose service networking.

## Best Practices for Platform Engineers

### Security Considerations

**Secrets Management**

Woodpecker stores secrets encrypted at rest. Never hardcode credentials in pipeline files:

```yaml
# ❌ Never do this
steps:
  - name: deploy
    image: alpine
    commands:
      - echo "password123" | docker login registry.example.com -u admin --password-stdin

# ✅ Use Woodpecker secrets
steps:
  - name: deploy
    image: alpine
    commands:
      - echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
    secrets:
      - registry_user
      - registry_password
```

Secrets can be scoped to:

- **Repository level**: Available only to a specific repo
- **Organization level**: Shared across all repos in an org
- **Pipeline events**: Restrict secrets to `push` events only (not `pull_request`) to prevent exposure in fork PRs

**Network Isolation**

The Woodpecker agent requires access to the Docker socket. In production, isolate it:

```yaml
# docker-compose.yml — Hardened agent
woodpecker-agent:
  image: woodpeckerci/woodpecker-agent:latest
  security_opt:
    - no-new-privileges:true
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock:ro
  networks:
    - ci-internal
  deploy:
    resources:
      limits:
        cpus: "2.0"
        memory: 2G
```

Consider running the agent in a dedicated VM or using Docker-in-Docker (DinD) to avoid exposing the host Docker socket entirely.

**Docker Socket Security**

Mounting the Docker socket gives the agent root-equivalent access to the host. Mitigations:

1. **Use rootless Docker** where possible
2. **Limit agent permissions** with `userns-remap`
3. **Run agents on dedicated CI hosts** — not on production servers
4. **Audit container images** used in pipelines — only allow trusted base images

### Scaling and High Availability

**Horizontal Scaling with Multiple Agents**

Woodpecker separates the server (coordination) from agents (execution). Scale by adding agents:

```yaml
# docker-compose.yml — Multi-agent setup
services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:latest
    # ... server config ...

  agent-1:
    image: woodpeckerci/woodpecker-agent:latest
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
      - WOODPECKER_MAX_WORKFLOWS=4
      - WOODPECKER_FILTER_LABELS=platform=linux/amd64
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  agent-2-arm:
    image: woodpeckerci/woodpecker-agent:latest
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
      - WOODPECKER_MAX_WORKFLOWS=2
      - WOODPECKER_FILTER_LABELS=platform=linux/arm64
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
```

Agent labels allow you to route workloads to specific hardware — ARM builds to ARM agents, GPU workloads to GPU-equipped hosts.

**Database for Production**

SQLite is fine for small setups. For HA, switch to PostgreSQL:

```yaml
environment:
  - WOODPECKER_DATABASE_DRIVER=postgres
  - WOODPECKER_DATABASE_DATASOURCE=postgres://woodpecker:secret@db:5432/woodpecker?sslmode=require
```

**Server Redundancy**

For true HA, run multiple server instances behind a load balancer with a shared PostgreSQL database. The Woodpecker server is stateless — all state lives in the database.

### Monitoring and Logging Integration

**Prometheus Metrics**

Woodpecker exposes a `/metrics` endpoint for Prometheus scraping:

```yaml
# prometheus.yml
scrape_configs:
  - job_name: "woodpecker"
    static_configs:
      - targets: ["woodpecker-server:8000"]
    metrics_path: /metrics
```

Key metrics to monitor:

- `woodpecker_pipeline_count` — Total pipeline executions
- `woodpecker_pipeline_time` — Pipeline duration histograms
- `woodpecker_running_jobs` — Currently executing jobs
- `woodpecker_pending_jobs` — Queued jobs waiting for agents
- `woodpecker_worker_count` — Connected agents

**Grafana Dashboard**

Pair Prometheus with Grafana for visualization. The community provides pre-built dashboards, or build your own tracking:

- Pipeline success/failure rates over time
- Average build duration per repository
- Agent utilization and capacity
- Queue depth trends

**Structured Logging**

Forward Woodpecker logs to your centralized logging stack (ELK, Loki, etc.):

```yaml
woodpecker-server:
  logging:
    driver: "json-file"
    options:
      max-size: "10m"
      max-file: "3"
      tag: "woodpecker-server"
```

Or use Loki with the Docker plugin for zero-config log aggregation:

```yaml
woodpecker-server:
  logging:
    driver: loki
    options:
      loki-url: "http://loki:3100/loki/api/v1/push"
      loki-batch-size: "400"
```

## Migrating Pipelines from GitHub Actions

If you have existing GitHub Actions workflows, migrating to Woodpecker is straightforward. Here's a side-by-side comparison:

### GitHub Actions vs. Woodpecker Syntax

**GitHub Actions:**

```yaml
# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    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 test
      - run: npm run build
```

**Woodpecker equivalent:**

```yaml
# .woodpecker.yml
steps:
  - name: build
    image: node:20-alpine
    commands:
      - npm ci
      - npm test
      - npm run build

when:
  branch: main
  event: [push, pull_request]
```

Notable differences:

- **No checkout step**: Woodpecker automatically clones the repository
- **No setup steps**: The Docker image provides the runtime — `node:20-alpine` already has Node.js 20
- **Simpler syntax**: No jobs/steps nesting, no `runs-on`, no marketplace actions
- **Faster startup**: No VM provisioning — containers start in seconds

### Common Migration Patterns

<table>
  <thead>
    <tr>
      <th>GitHub Actions Pattern</th>
      <th>Woodpecker Equivalent</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>
        <code>actions/checkout@v4</code>
      </td>
      <td>Automatic (built-in)</td>
    </tr>
    <tr>
      <td>
        <code>actions/setup-node@v4</code>
      </td>
      <td>
        Use <code>node:20-alpine</code> image
      </td>
    </tr>
    <tr>
      <td>
        <code>actions/cache@v4</code>
      </td>
      <td>Volume mounts or registry cache</td>
    </tr>
    <tr>
      <td>
        <code>docker/build-push-action@v5</code>
      </td>
      <td>
        <code>woodpeckerci/plugin-docker-buildx</code>
      </td>
    </tr>
    <tr>
      <td>
        <code>appleboy/ssh-action@v1</code>
      </td>
      <td>
        <code>appleboy/drone-ssh</code> plugin
      </td>
    </tr>
    <tr>
      <td>
        <code>actions/upload-artifact@v4</code>
      </td>
      <td>S3/MinIO plugin or volume mounts</td>
    </tr>
    <tr>
      <td>Job matrix</td>
      <td>
        <code>matrix:</code> block
      </td>
    </tr>
    <tr>
      <td>
        <code>secrets.GITHUB&#95;TOKEN</code>
      </td>
      <td>
        <code>from&#95;secret: github&#95;token</code>
      </td>
    </tr>
  </tbody>
</table>

## Pros and Cons of Self-Hosted Woodpecker

### Advantages

- **Full infrastructure control**: You own the hardware, the network, the data. No third-party has access to your source code or build artifacts.
- **Cost efficiency at scale**: A $20/month VPS can run hundreds of pipelines daily. At equivalent GitHub Actions usage, you'd pay significantly more.
- **Docker-native simplicity**: If your team already thinks in containers, Woodpecker feels natural. No proprietary abstractions.
- **Portability**: Pipelines are forge-agnostic. Switch from GitHub to Gitea tomorrow — same `.woodpecker.yml` works.
- **Low resource footprint**: Unlike Jenkins or GitLab Runner, Woodpecker idles at under 100MB RAM. It earns its keep on small servers.
- **Sub-second pipeline starts**: No waiting for VM provisioning. Containers start almost instantly, especially with cached images.
- **Transparent and auditable**: Open-source codebase. You can inspect, patch, and contribute back. No black-box runner behavior.

### Challenges

- **You own the uptime**: Self-hosting means you're the SRE. Server goes down? Pipelines stop. Plan for monitoring and alerting.
- **Initial setup complexity**: Setting up TLS, OAuth, reverse proxy, and Docker networking requires systems knowledge. It is not a one-click install.
- **Smaller plugin ecosystem**: GitHub Actions has thousands of marketplace actions. Woodpecker's plugin ecosystem is smaller, though Drone plugins are compatible.
- **No built-in UI for secrets rotation**: Secret management works but lacks the polish of GitHub's UI. Consider integrating with Vault for production secrets.
- **Manual updates**: You're responsible for keeping Woodpecker, Docker, and the host OS updated and patched.
- **Limited enterprise features**: No built-in RBAC beyond admin/user, no audit logging out of the box. Fine for small teams, potentially limiting for large organizations.

### Cost Comparison

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>GitHub Actions (Team)</th>
      <th>Self-Hosted Woodpecker</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>
        <strong>Base cost</strong>
      </td>
      <td>$4/user/month</td>
      <td>$0 (open-source)</td>
    </tr>
    <tr>
      <td>
        <strong>Compute (2,000 min/month)</strong>
      </td>
      <td>~$16/month</td>
      <td>~$20/month (VPS)</td>
    </tr>
    <tr>
      <td>
        <strong>Compute (10,000 min/month)</strong>
      </td>
      <td>~$80/month</td>
      <td>~$20/month (same VPS)</td>
    </tr>
    <tr>
      <td>
        <strong>ARM builds</strong>
      </td>
      <td>2x pricing</td>
      <td>Native (ARM VPS)</td>
    </tr>
    <tr>
      <td>
        <strong>Storage (artifacts)</strong>
      </td>
      <td>500 MB included, then $0.25/GB</td>
      <td>Your disk (included)</td>
    </tr>
    <tr>
      <td>
        <strong>Private repos</strong>
      </td>
      <td>Included</td>
      <td>Included</td>
    </tr>
    <tr>
      <td>
        <strong>Maintenance</strong>
      </td>
      <td>$0</td>
      <td>Your engineering time</td>
    </tr>
  </tbody>
</table>

## Conclusion and Future Outlook

Migrating from GitHub Actions to Woodpecker was not about rejecting GitHub — it's about choosing the right tool for the right constraints. As a platform engineer, owning the CI/CD layer gives me:

- **Predictable costs** regardless of pipeline volume
- **Full observability** into build infrastructure
- **Hardware flexibility** — ARM, GPU, or specialized build environments
- **Data sovereignty** — code and artifacts never leave my infrastructure
- **Vendor independence** — the freedom to change forges without rewriting pipelines

Woodpecker is not a silver bullet. If you're a small team with simple needs, GitHub Actions is hard to beat for convenience. But if you're managing multiple services, care about infrastructure ownership, or simply want to stop paying per-minute for compute you already own — Woodpecker is a remarkably capable alternative.

### What's Next

Looking ahead, I'm exploring:

- **Kubernetes-native agents**: Running Woodpecker agents as Kubernetes pods for elastic scaling — spin up agents on demand and release resources when idle.
- **Autoscaling with cloud VMs**: Using Hetzner Cloud API to provision spot instances as Woodpecker agents during peak load, tearing them down automatically after builds complete.
- **Centralized secret management**: Integrating HashiCorp Vault with Woodpecker for dynamic secrets — database credentials, API tokens, and certificates that rotate automatically.
- **Pipeline-as-code templates**: Building a shared library of `.woodpecker.yml` templates that standardize CI/CD patterns across all repositories in the organization.

![Woodpecker deployment running on the server with active agents connected, visible in the admin panel](https://storage-portfolio.monzim.com/blogs/from-github-actions-to-woodpecker/woodpecker-deployment-running-on-the-server-698cd15c344ed.webp)

## Appendix

### Sample `.env` File

```bash
# Woodpecker Server
WOODPECKER_HOST=https://ci.yourdomain.com
WOODPECKER_GITHUB=true
WOODPECKER_GITHUB_CLIENT=Iv1.xxxxxxxxxxxxxxxx
WOODPECKER_GITHUB_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
WOODPECKER_AGENT_SECRET=64_char_hex_string_generated_with_openssl_rand
WOODPECKER_ADMIN=your-github-username
WOODPECKER_LOG_LEVEL=info
```

### Complete Docker Compose (Production-Ready)

```yaml
# docker-compose.yml — Production Woodpecker setup
# Gist: https://gist.github.com/monzim/c4bbe8c0336bff097e0fc5014c09bb51

services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:latest
    container_name: woodpecker-server
    restart: unless-stopped
    ports:
      - "8000:8000"
    volumes:
      - woodpecker-server-data:/var/lib/woodpecker/
    environment:
      - WOODPECKER_HOST=${WOODPECKER_HOST}
      - WOODPECKER_OPEN=false
      - WOODPECKER_GITHUB=${WOODPECKER_GITHUB}
      - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}
      - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}
      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
      - WOODPECKER_ADMIN=${WOODPECKER_ADMIN}
      - WOODPECKER_LOG_LEVEL=${WOODPECKER_LOG_LEVEL:-info}
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8000/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3

  woodpecker-agent:
    image: woodpeckerci/woodpecker-agent:latest
    container_name: woodpecker-agent
    restart: unless-stopped
    depends_on:
      woodpecker-server:
        condition: service_healthy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
      - WOODPECKER_MAX_WORKFLOWS=4
      - WOODPECKER_HEALTHCHECK=true
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 2G
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"

volumes:
  woodpecker-server-data:
    driver: local
```

### Resources and References

- [Woodpecker CI Documentation](https://woodpecker-ci.org/docs/intro)
- [Woodpecker CI GitHub Repository](https://github.com/woodpecker-ci/woodpecker)
- [Woodpecker Plugin Index](https://woodpecker-ci.org/plugins)
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Traefik Proxy Documentation](https://doc.traefik.io/traefik/)
- [Prometheus Monitoring](https://prometheus.io/docs/introduction/overview/)

_This post reflects my experience as a platform engineer running Woodpecker CI in production. Your mileage may vary depending on team size, workload patterns, and infrastructure constraints. As always, evaluate tools in the context of your specific requirements._
