Self-Hosted CI with Woodpecker: The HeraldStack Pipeline

July 19, 2026 · 4 min · 803 words · Bryan Chasko

The entire HeraldStack CI surface runs on one machine. A single AMD RX 6700 XT workstation — rocm-aibox at 192.168.4.53 — handles every push, every PR, every deploy across 35 repositories. The engine is Woodpecker CI, self-hosted, containerized, and wired directly into GitHub via webhooks.

the stack

Woodpecker server (woodpeckerci/woodpecker-server:v3.14.1) and its paired agent run as Docker containers on rocm-aibox. The server listens on port 8210 and exposes both the web dashboard and an admin API for programmatic access. The agent picks up jobs from the server queue and executes them in ephemeral Docker containers — each pipeline step gets a fresh environment, no state bleeds between runs.

Both containers share the host with my development environment. Same hardware that runs local inference, MCP servers, and agent sessions also builds, tests, and deploys. No idle cloud VMs burning credits between pushes.

repo coverage

35 repos registered across BryanChasko/* and chasko-labs/* GitHub orgs. Each repo carries its pipeline definition in a .woodpecker/ directory — YAML files declaring steps, services, and triggers. GitHub webhooks fire on push and pull request events, Woodpecker picks them up, spins containers, runs the pipeline.

Pipeline configs are version-controlled alongside the code they build. No external pipeline editor, no separate config repo. The definition lives where the code lives.

secrets and credentials

Zero secrets in pipeline YAML. Every credential consumed during a pipeline run comes from AWS SSM Parameter Store at execution time. The pattern:

  • Pipeline step assumes an IAM role via OIDC (sts:AssumeRoleWithWebIdentity)
  • The assumed role reads parameters from SSM
  • Credentials exist only in the ephemeral container’s memory for the duration of that step

No long-lived keys. No .env files. No Woodpecker-native secret store holding tokens that rot. The OIDC flow means credentials are scoped, short-lived, and auditable through CloudTrail.

For AWS operations — S3 deploys, CloudFront invalidations, ECR pushes — the pipeline assumes a workload-specific role with minimum-necessary permissions. Each pipeline gets only what it needs for exactly as long as it needs it.

what runs through it

The use cases span the full build-test-deploy surface:

  • lint gates — eslint, clippy, ruff, markdownlint. PRs that fail lint do not merge
  • test runners — jest, pytest, cargo test. Parallel where independent, sequential where not
  • Docker image builds — Docker-in-Docker pattern for container packaging, push to GHCR
  • S3 deploys — Hugo sites, React builds, static assets synced with --delete
  • CloudFront invalidations — scoped to changed paths via git diff --name-only

Each repo’s pipeline is shaped to its stack. A Hugo site runs hugo build then syncs to S3. A chrome extension runs lint, jest, packages the zip. A Rust MCP server runs cargo clippy, cargo test, builds the release binary, pushes the container image. The YAML reflects what the project actually needs — nothing more.

why self-hosted

The reasoning is straightforward:

  • zero vendor lock-in — Woodpecker is open source, Apache-2.0 licensed. The config is portable YAML. Moving to another runner means rewriting steps, not rearchitecting
  • full control — I own the agent, the network, the storage. No opaque runner environments where a dependency breaks and the vendor’s changelog is my only diagnostic tool
  • runs on existing hardware — rocm-aibox already runs 24/7 for inference and MCP services. CI is an incremental workload on an already-provisioned machine
  • no per-minute billing — 35 repos, some with multiple pipelines per push. At cloud CI rates that adds up. Here it costs electricity and the disk space for container images

The tradeoff is obvious: if the machine goes down, CI goes down. For a single-operator infrastructure this is acceptable. The machine is UPS-backed, the data is replicated, and the blast radius of a CI outage is “my PRs queue until I fix it.”

the admin API

Port 8210 exposes Woodpecker’s REST API. Programmatic access for triggering builds, reading pipeline status, managing repos and secrets. The admin token lives in SSM at the expected path — fetched at runtime by any automation that needs it, never hardcoded anywhere.

Agent sessions can query pipeline status, trigger rebuilds after config changes, and read build logs without touching the web UI. The API is the interface; the dashboard is the fallback.

shared hardware model

The agent runs on the same box where I write code, run local models, and operate MCP servers. This is intentional. CI workloads are bursty — a push triggers a build, it runs for 30 seconds to 3 minutes, then the machine is idle again. Between builds, those cores and that memory serve inference requests and agent sessions.

There is no resource contention in practice. Docker’s cgroup limits keep pipeline containers from starving the host. The agent is configured with a concurrency limit that matches what the hardware can sustain without degrading interactive work.

One machine. 35 repos. Every push tested, every deploy automated, every secret ephemeral. The pipeline runs where the work happens.