← All writing

Software & systems

Running Woodpecker on Macs without a Kubernetes cluster

10 min read

I have a bunch of older MacBook Pros and Mac minis, all Apple silicon. They still have useful compute capacity, and putting them to work on CI/CD seemed like a good way to extend their lives.

My previous setup used GitHub Actions Runner Controller (ARC). Each worker Mac ran a Lima VM, those VMs formed a k3s cluster, and a dedicated Mac mini acted as the cluster’s control-plane node. ARC managed the GitHub Actions runners inside that cluster, giving me a way to scale runner capacity across the machines.

It also gave me a cluster to maintain.

Intermittent power issues made that maintenance painful. GitHub Actions downtime added another source of disruption. The hardware was mine, but getting a build to run still depended on several layers being healthy at the same time.

That experience is why I’m exploring a simpler setup with Woodpecker and woodpecker-macos-container: a native macOS agent that runs Linux CI steps through Apple’s container runtime.

What I want to simplify

The old arrangement made sense when the goal was to coordinate a pool of GitHub Actions runners. Lima supplied the Linux environment, k3s coordinated the cluster, and ARC connected that capacity to GitHub Actions.

For my fleet, though, the operational question became: how much infrastructure do I want to repair before I can run a test?

A power interruption could leave me checking the physical machine, its VM, the cluster, and then the runner layer. Those are useful tools individually, but together they created more state than I wanted to manage for this workload.

One particularly frustrating failure happened when k3s nodes went offline. In my setup, the ARC runners on those nodes could get stuck while still counting towards the configured maximum number of runners. The runner pool could therefore be at its limit even though those runners were no longer doing useful work.

That left the GitHub Actions workflows stuck too. Losing a node meant more than losing some compute capacity: stuck runners could occupy the slots needed to make progress, even with other machines available. This was exactly the kind of failure that made maintaining the cluster feel disproportionate to the CI work it was supposed to support.

Another frustrating case was instability in the GitHub Actions APIs. Nothing happened in the ARC cluster, leaving me trying to work out why runners were not being created or picking up work. I spent time debugging my own setup before establishing that GitHub Actions was experiencing downtime.

The silence made it hard to tell whether the problem was local or upstream. Between runners stuck after node failures and an idle cluster during API outages, I was spending too much time figuring out which layer had stopped making progress before I could address the actual problem.

With Woodpecker, the arrangement I’m evaluating looks like this:

Repository event
    |
    v
Woodpecker server
    |
    +--> Mac agent --> Apple container VMs
    |
    +--> Mac agent --> Apple container VMs

The server receives events and schedules workflows. Each eligible Mac runs an agent, and the agent’s backend creates the execution environments. There is still a server to operate and back up, but the worker machines do not need to form a Kubernetes cluster.

Adding another worker means configuring another agent. That provides a path to more capacity; it does not automatically provision machines or reproduce ARC’s runner autoscaling.

A Mac host does not mean a macOS build

The first constraint is important for anyone looking at an old Mac collection.

This backend requires an Apple silicon Mac running macOS 26 or newer. My fleet meets the architecture requirement; each worker still needs the supported operating system. If you have Intel Macs, this particular backend cannot run on them.

The workloads are Linux ARM64, even though the agent itself runs on macOS. This is useful for container-based builds, tests, and tooling. Native macOS or Xcode builds need a different execution environment.

Apple’s container project runs Linux containers in lightweight virtual machines and uses OCI-compatible images. The backend connects that runtime to Woodpecker’s workflow execution model.

At the repository snapshot used for this article, the requirements are:

Component Required version
Mac Apple silicon
macOS 26 or newer
Apple container CLI Exactly 1.5.0
Go, when building the agent 1.26.0 or newer
Woodpecker server 3.18.1

The exact container CLI version is deliberate: this release depends on its JSON output shapes. Installing a newer CLI is not enough to satisfy the backend’s compatibility check.

Prepare one worker first

I would start with one eligible Mac and one small workflow before expanding to the rest of the compatible machines.

Install Apple’s signed container package for 1.5.0 from its releases page, and install the required Go toolchain. Then build the custom agent:

git clone https://github.com/ericluwj/woodpecker-macos-container.git
cd woodpecker-macos-container

# Use the snapshot described in this article.
git checkout 16c521f59b513577fa80d7d5ade7736fae16be2a
make build

container system start
container run --rm alpine:3.22 echo ready

Run the container service as the same macOS user that will run the agent. Complete the kernel preparation and any macOS Local Network permission prompts during setup.

The backend checks that the container service is available. It does not start the service, install its prerequisites, or change global DNS for you. The small Alpine command separates a runtime problem from an agent problem before there is a workflow involved.

Connect the agent to Woodpecker

These steps assume a Woodpecker 3.18.1 server is already configured with its forge integration, a repository is enabled, and its gRPC endpoint is reachable from the Mac. Setting up that server is a separate part of the deployment; the custom agent does not replace it.

Obtain an agent token from the server administrator, store it in a private file readable by the agent user, and configure the agent:

mkdir -p "$HOME/.config/woodpecker"

export WOODPECKER_SERVER=ci.example.com:9000
export WOODPECKER_AGENT_SECRET_FILE="$HOME/.config/woodpecker/agent-secret"
export WOODPECKER_GRPC_SECURE=true
export WOODPECKER_GRPC_SKIP_VERIFY=false
export WOODPECKER_AGENT_CONFIG_FILE="$HOME/.config/woodpecker/apple-agent.conf"
export WOODPECKER_BACKEND=apple-container

# Start conservatively on a small machine.
export WOODPECKER_MAX_WORKFLOWS=1
export WOODPECKER_BACKEND_APPLE_CPUS=2
export WOODPECKER_BACKEND_APPLE_MEMORY=1G

./bin/woodpecker-agent-apple

Replace ci.example.com:9000 with the actual gRPC address, and create the secret file before launching the agent. This example assumes the endpoint serves TLS with a certificate the Mac trusts; the TLS settings must match the server deployment.

The executable matters. This is the custom woodpecker-agent-apple binary built from the repository. Setting WOODPECKER_BACKEND=apple-container on an ordinary upstream agent does not install the backend.

Woodpecker handles registration and scheduling. Its agent configuration documentation explains the token and connection settings; the backend repository documents the Apple-specific options.

Give it a small workflow

Add a .woodpecker.yaml file to the enabled repository:

labels:
  backend: apple-container

steps:
  - name: build
    image: alpine:3.22
    commands:
      - uname -s
      - uname -m
      - printf hello > artifact

  - name: verify
    image: alpine:3.22
    commands:
      - test "$(cat artifact)" = hello

The backend label targets agents using this backend. The platform reported by the agent is linux/arm64, and every image used by the workflow needs a compatible Linux ARM64 manifest.

This example checks two useful properties: commands execute inside Linux, and the file written by the first step is available to the second.

Each step gets its own VM, while steps in the same workflow share a workspace and a custom network. Services are supported too. A per-workflow CoreDNS helper provides names such as postgres, so concurrent workflows can use the same service names on their separate networks.

That does not make a database ready as soon as its name resolves. A workflow using Postgres should still wait for readiness before running migrations or tests. The repository includes a service example showing that pattern.

Capacity needs a little arithmetic

The configured CPU and memory limits apply to each step VM, not to a whole workflow. Each workflow also has a DNS helper VM with one CPU and 256 MiB of memory.

That matters when increasing WOODPECKER_MAX_WORKFLOWS. Two concurrent workflows with parallel steps and services can create substantially more than two VMs.

I would increase concurrency only after observing memory pressure, job duration, and the behavior of the actual pipeline. Parallel steps also share a writable workspace, so they need to avoid conflicting writes.

Images remain cached after workflow cleanup. Disk usage therefore still needs attention, especially on older machines with small drives.

What remains my responsibility

Removing the cluster does not remove the need to operate CI.

A power failure can still interrupt a workflow. Normal teardown cleans up the workflow’s containers, DNS helper, network, and directory, but a hard crash requires administrator recovery. The backend records an ownership.json manifest for each workflow; recovery should follow the repository’s ownership-checking procedure, rather than deleting every container on the Mac.

The worker startup procedure also needs to bring up the container service before the agent. Agent supervision, server backups, power management, and recovery drills remain part of the setup.

There are compatibility boundaries as well. The backend rejects privileged workloads, arbitrary host mounts, host networking, published host ports, and several other runtime options. Workflows that expect a Docker socket or Docker-in-Docker need a different approach. Private image access currently uses administrator credentials established with container registry login; Woodpecker-provided registry credentials are rejected.

The VM separation is intended for ordinary CI workload isolation. The repository does not describe it as a hostile multi-tenant security boundary.

Finally, moving execution to Woodpecker reduces reliance on the GitHub Actions service, but a repository hosted on GitHub still depends on GitHub for source access, webhook delivery, and status updates. It would be misleading to call this independent of GitHub availability.

Where I’m heading

The appeal is a worker model that is easier to understand: a Mac, a container runtime, and an agent connected to a CI server.

For my Apple silicon fleet, that gives me something concrete to evaluate against the maintenance burden of my Lima, k3s, and ARC setup. The remaining host prerequisite is macOS 26 or newer, and production readiness still needs to be earned.

My next useful test is recovery: run a representative pipeline, interrupt a worker, and document what it takes to get that worker back into service. Faster builds would be welcome, but a CI system that is easier to recover is the improvement I’m looking for.