Skip to content
Stribog

CI/CD

All writing

Own Your Pipeline: Forgejo, Woodpecker, and Zot as a GitHub Actions Exit

A self-hosted CI/CD GitHub Actions alternative: Forgejo for git, Woodpecker for pipelines, Zot for OCI artifacts — the whole delivery chain as owned infrastructure.

Stribog16 min read
sovereigntyopen sourceoptionality

Most teams can tell you exactly where their production data lives and who can compel access to it. Ask the same questions about the pipeline that *builds* production — the forge holding every line of source, the CI that runs with credentials to your registries and clusters, the registry shipping the images that actually run — and the answers get vague. The delivery chain is load-bearing infrastructure that most organizations have quietly rented from a single vendor, and never priced the dependency. Then 2025 and 2026 made the bill legible.

This is not an anti-GitHub polemic. GitHub is an excellent product, and for many teams it remains the correct choice. But "correct for now" and "something you could never leave" are different postures, and the gap between them is exactly the optionality this practice exists to defend. This article is the concrete version of that argument: a complete forge-to-registry exit built from owned, open-source components — Forgejo for git and code review, Woodpecker for pipelines, Zot for the OCI artifact registry — wired into a delivery chain you operate, audit, and can replace one layer at a time.

It is deliberately distinct from the rest of our delivery writing. The GitOps and supply-chain security playbooks assume the forge and CI already exist and focus on what happens after the image is built. This one goes upstream of all of that — to the source of truth and the build itself — because the layer you do not own is the layer that surprises you. We will cover what each system is, config you can read, how the three combine into a hermetic and provable build, and the part most guides skip: how to leave each component without it becoming the next lock-in.

The Year the Pipeline Stopped Being Free

The case for owning the delivery chain used to be mostly theoretical. Three concrete events in 2025 and 2026 turned it operational. None is a reason to panic; together they are a reason to have an exit ramp — a different and more durable thing.

The first was a supply-chain compromise that hit where CI is most exposed. On 14 March 2025, the widely used tj-actions/changed-files GitHub Action was compromised — CVE-2025-30066. An attacker rewrote the action's existing version tags to point at malicious code, so even workflows pinned to a tag like v44 suddenly executed it. The payload dumped the runner's memory into the build log; on public repositories that meant secrets — tokens, signing keys, cloud credentials — printed in plaintext for anyone to read. More than 23,000 repositories referenced the action, and a near-identical pattern hit reviewdog/action-setup the same week. The lesson is not "GitHub is unsafe." It is that a shared hosted runner executing third-party code referenced by a *mutable* tag has an enormous, mostly invisible blast radius.

The second event was about data, not breach. Through 2026 GitHub updated its terms so that interaction data from the consumer Copilot tiers — including code from active sessions, in private repositories — is used to train models by default, with opt-out left to the customer; Business and Enterprise tiers are exempt. Reasonable people disagree about the privacy calculus, but the governance point is sharp: source code is the most sensitive IP most engineering companies own, and "used for training unless you remember to opt out" is not a posture procurement or compliance can sign off without a fight. It made the abstract question — *who else can do something with our code?* — suddenly concrete.

The third was a pricing tremor: in December 2025 GitHub floated charging a per-minute fee even for *self-hosted* runners, then walked it back within days. The fee never landed, but the signal did — the economics of a platform you do not control can change on its schedule, not yours. Stack these against the standing pressure of EU data-residency rules — the same NIS2, DORA, and EU AI Act convergence reshaping where regulated workloads may run — and the pipeline stops looking like a settled commodity and starts looking like a dependency worth being able to exit.

What "Owning the Pipeline" Actually Means

Owning the delivery chain does not mean rebuilding GitHub. It means recognizing that the chain is three distinct responsibilities, each an independently owned, independently replaceable component connected only by open protocols. The forge is the source of truth: git history, identities, code review, issues. CI turns a commit into tested, signed artifacts. The registry stores and serves those artifacts to whatever runs them. GitHub bundles all three behind one login — convenient until the bundle itself is the dependency you cannot price.

Unbundled, the contracts between the layers are boringly standard — and that is the whole point. The forge speaks git over SSH and HTTPS and authenticates CI over OAuth. CI talks to the forge over that same OAuth and webhooks, and pushes artifacts using the OCI distribution spec. The registry speaks nothing but OCI. There is no proprietary glue: replace any one box with another that speaks the same protocol and the other two never notice. That substitutability at every seam is what turns "self-hosted" from a synonym for "locked into our own server" into genuine optionality.

The whole delivery chain as owned infrastructure: Forgejo holds the source, Woodpecker runs every step in a container, Zot stores the signed artifacts. Git, OAuth, and the OCI distribution spec are the only contracts between them — which is exactly why any one box can be swapped without touching the others.

Forgejo: The Forge, Hard-Forked for Good Reason

Forgejo's origin story is itself a sovereignty lesson. Gitea — the lightweight, self-hostable forge many teams already ran — was moved under a for-profit company in 2022. The community response was Forgejo, a fork stewarded by Codeberg e.V., a German non-profit. In February 2024 it committed to a hard fork, diverging the codebase rather than perpetually rebasing on an upstream it did not govern, and in August 2024 it relicensed from MIT to GPLv3+ — a deliberate move to copyleft so improvements to the forge stay open. The release line ships on a steady cadence, with Forgejo v13 landing in October 2025 and security windows published in advance.

The governance is the feature. A forge is where your entire engineering history lives — exactly the kind of decade-horizon dependency where the *project's* structure matters more than any single release. This is the same distinction that ran through our analysis of MinIO going dark: open *source* and an open *project* are different guarantees. A non-profit with copyleft and community-elected maintainers cannot be acquired out from under you the way a single-vendor project can. Open source is a method, not a checkbox — and the method is what protects the forge over the long game.

On the CI side, Forgejo ships Forgejo Actions, executed by forgejo-runner, which is built on act — the same engine that runs GitHub Actions workflows locally. Workflows live in .forgejo/workflows/ and use YAML intentionally *familiar* to anyone who has written GitHub Actions. Be precise, though: familiar is not a guaranteed drop-in. Actions leaning on GitHub-specific APIs or the exact ubuntu-latest image may not behave identically, some job keys are ignored, and the default container is Debian-based. For many repos Forgejo Actions is the lowest-friction path off GitHub; for others, the honest move is to rewrite onto a CI engine that never pretended to be GitHub at all — which is where Woodpecker comes in.

bash
# Register a self-hosted runner against your Forgejo instance.
# The token comes from Forgejo > Settings > Actions > Runners.
forgejo-runner register \
  --no-interactive \
  --instance https://forge.example.internal \
  --token "$RUNNER_TOKEN" \
  --name forge-runner-01 \
  --labels docker:docker://node:22-bookworm,self-hosted:host

# Runners are yours: pinned images, scoped network, no shared tenancy.
forgejo-runner daemon --config /etc/forgejo-runner/config.yaml
Registering a Forgejo Actions runner. The runner is a process you operate on hardware you control — the label map fixes exactly which container images a workflow may run on, so there is no shared, mutable runner pool executing third-party code with your secrets in scope.

Woodpecker: Pipelines as Containers, Not a Hosted Black Box

Woodpecker is a community fork of Drone CI, taken at Drone's last Apache-2.0 release before that project went proprietary — so Woodpecker is Apache-2.0 and stays that way. Its v3.0 release landed in January 2025, and the model is refreshingly small: a server that talks to your forge and orchestrates work, and one or more agents that execute it, with every pipeline step running in its own container. There is no bespoke step DSL hiding what happens — a step is an image plus a command, the same OCI container abstraction you already understand, runnable identically on a laptop, an agent, or a Kubernetes pod.

Woodpecker connects to the forge over OAuth, with Forgejo and Gitea first-class alongside GitHub and GitLab. You authorize it once, it registers webhooks, and a push or pull request triggers a pipeline defined in-repo as .woodpecker.yml. The backend is pluggable: docker for a single host, kubernetes to run each step as a pod with real resource requests, or local. Because steps are just containers, a "plugin" is merely a published image with a documented interface — the same supply-chain hygiene you apply to production images applies, unchanged, to CI steps.

yaml
# .woodpecker.yml — every step is a container; the unit of work is an OCI image.
when:
  - event: [push, pull_request]
    branch: main

steps:
  test:
    image: golang:1.24-bookworm        # pinned, mirrored into your own Zot
    commands:
      - go test ./... -race -count=1

  build:
    image: golang:1.24-bookworm
    commands:
      - CGO_ENABLED=0 go build -o app ./cmd/app
    depends_on: [test]

  publish:
    image: woodpeckerci/plugin-kaniko   # daemonless build, no privileged Docker socket
    settings:
      registry: zot.example.internal
      repo: zot.example.internal/platform/app
      tags: ${CI_COMMIT_SHA}
      username: { from_secret: zot_user }
      password: { from_secret: zot_token }
    depends_on: [build]
A Woodpecker pipeline: test, build, then publish to your own Zot registry with a daemonless Kaniko build. Steps are plain containers with explicit dependencies — no hidden runner image, no privileged Docker socket, and every base image pinned and mirrored into infrastructure you control.

The contrast with a hosted runner is not about features but visibility and control. You can read every line of what a step does because it is a container and a command. You set the agents' network policy, so a build step can be denied egress to anywhere except your registry, and you decide whether secrets are mounted at all for pull requests from forks. None of this is exotic — it is the ordinary discipline of a platform engineering golden path, applied to the build tier instead of the deploy tier. Teams already on Kubernetes can extend this model further still: running self-hosted GitHub Actions runners with ARC lets you keep GitHub as the forge while owning the execution layer on your own cluster.

Zot: A Registry That Is Only a Registry

The last link in the chain is where built artifacts live, and the one teams most often leave on a hosted service out of habit. Zot is a CNCF Sandbox project (accepted December 2022) with a pointed philosophy: it implements the OCI Distribution Spec and nothing else. No Docker daemon dependency, no sprawl of microservices — a single Go binary that serves and stores OCI content. That minimalism is a security posture: a smaller surface, fewer moving parts, and a registry that treats images and non-image artifacts (SBOMs, signatures, attestations) as first-class OCI objects rather than bolt-ons.

Zot earns its place through what it builds in. It integrates Trivy for continuous, scheduled CVE scanning of stored images, so vulnerability status is a property of the registry rather than a report you remember to run. It stores Cosign and Notation signatures and attestations as OCI artifacts referenced to the image they describe — what later lets an admission controller verify provenance. And it supports sync and mirroring, so pulling upstream base images into your own registry and pinning to them is first-class, not a hack. Against Harbor, Zot trades a broad feature surface for a small, OCI-pure one; against the bare distribution registry, it adds the scanning and artifact-awareness a real supply chain needs.

json
{
  "storage": { "rootDirectory": "/var/lib/zot" },
  "http": { "address": "0.0.0.0", "port": 5000 },
  "extensions": {
    "search": { "enable": true },
    "scrub": { "interval": "24h" },
    "trivy": { "enable": true },
    "scan": { "enable": true, "interval": "6h" },
    "sync": {
      "registries": [{
        "urls": ["https://registry-1.docker.io"],
        "onDemand": true,
        "content": [{ "prefix": "library/golang" }]
      }]
    }
  }
}
A Zot config: OCI storage, continuous Trivy CVE scanning every six hours, and on-demand sync that mirrors upstream base images into your own registry. Once a base image lives in Zot, your builds pin to your copy — not to a tag on a registry you do not control.

Self-hosting the registry also closes the residency gap at the artifact tier: the images you deploy and the signatures that prove what they are sit on disks in a jurisdiction you choose. That is the same audit-grade posture we hold for signing and admission enforcement in the supply chain — except now the registry holding the evidence is part of the boundary, not a third party you must trust and document.

Wiring It Together: A Hermetic, Provable Build

The point of owning all three layers is that the seams between them become places to enforce things a hosted bundle leaves implicit. The headline capability is a build whose provenance lives entirely on your infrastructure. A Woodpecker pipeline checks out a commit pinned by digest, builds in an isolated container, generates a Software Bill of Materials with Syft, signs the result with Cosign, and pushes the image alongside its SBOM and signature into Zot as OCI artifacts. Crucially, you can run your *own* Sigstore — a self-hosted Fulcio certificate authority and Rekor transparency log — so the attestation never has to leave your network to be trustworthy.

yaml
# Sign and attest against a self-hosted Sigstore — provenance stays on your network.
  sbom:
    image: anchore/syft:latest
    commands:
      - syft zot.example.internal/platform/app:${CI_COMMIT_SHA} -o spdx-json=sbom.spdx.json

  sign:
    image: gcr.io/projectsigstore/cosign:latest
    environment:
      COSIGN_FULCIO_URL: https://fulcio.example.internal
      COSIGN_REKOR_URL: https://rekor.example.internal
    commands:
      # Sign the image and attach the SBOM as an in-toto attestation.
      - cosign sign --yes zot.example.internal/platform/app:${CI_COMMIT_SHA}
      - cosign attest --yes --predicate sbom.spdx.json --type spdxjson \
          zot.example.internal/platform/app:${CI_COMMIT_SHA}
    depends_on: [publish, sbom]
Generate an SBOM with Syft, then sign the image and attach the SBOM as an attestation with Cosign — pointed at a self-hosted Fulcio and Rekor. The transparency-log entry that proves how the artifact was built is written to a log you operate, not a public endpoint.

This is where the sovereignty argument turns audit-grade. Because the runner is yours, the source is pinned by digest, and the base images come from your own Zot mirror, the build environment is something you can describe completely — the precondition for SLSA build provenance. A self-hosted chain reaches SLSA Build Level 2 readily; Level 3, which requires strong isolation between builds, is achievable with per-build ephemeral agents. The deploy side closes the loop: an admission controller refuses any image whose signature does not verify against your Fulcio root, exactly as in our Kyverno governance work. Provenance stops being a slide in a security review and becomes a gate that cannot be bypassed.

A provable build: pin the source by digest, generate an SBOM, sign against a self-hosted Fulcio, and record it in your own Rekor log — provenance that lives on your infrastructure and answers to your auditor, not to a hosted runner's opaque environment.

Migrating Off GitHub Actions Without a Big Bang

A forge migration sounds heavy and usually is not, because git is built for exactly this. The disciplined path is incremental: stand up Forgejo, mirror repositories into it while GitHub stays authoritative, run both pipelines in parallel during a bake period, and cut over per-repository once the owned chain is proven. Nothing is deleted until the new path has been green long enough to trust. Because every git clone is already a full backup, the migration's worst case is "keep using GitHub a little longer," not data loss.

bash
# 1. Mirror an existing repo into Forgejo (history, branches, tags — all of it).
git clone --mirror https://github.com/acme/platform.git
cd platform.git
git remote add forgejo https://forge.example.internal/acme/platform.git
git push --mirror forgejo

# 2. Keep GitHub as a read-only push mirror during the bake period, so both
#    forges stay in sync until every repo has cut over to the owned chain.
#    (Configure under Forgejo > Settings > Repository > Mirror settings.)

# 3. Translate workflows: Forgejo Actions for low-friction lift-and-shift,
#    or a clean rewrite onto .woodpecker.yml where the GitHub-specific bits
#    were doing more harm than good.
A non-destructive forge migration: mirror the full repository into Forgejo, keep the two in sync during a bake period, and cut over per-repo. Because git is distributed, every clone is a complete backup — the migration is reversible at every step.

Workflow translation is where to be honest about effort. Pipelines that are mostly checkout, build, test, push move with little friction. The real work sits in pipelines wired deep into the GitHub ecosystem — environments, OIDC federation to a specific cloud, marketplace actions with no analogue — which is also where you most benefit from rewriting onto Woodpecker's plain-container model rather than carrying GitHub-shaped assumptions into a new home. Either way, the migration is a chance to pin every action and base image by digest and pull them through your own Zot, retiring the mutable-tag risk the tj-actions incident weaponized.

The Exit Ramp From Your Own Exit

The failure mode of any self-hosting project is to escape one lock-in and build another — replacing a vendor you could not leave with a homegrown stack you also cannot leave. The architecture here is designed against that, on the same principle we apply to object storage and stateful data: keep every seam a standard protocol, and keep each component the smallest thing that does its job.

  • The forge speaks git. Your history is not in a proprietary format; it is git objects. Moving off Forgejo is git push --mirror to whatever comes next — the same move that brought you in. Federation work (ForgeFed/ActivityPub) is maturing but still experimental, so treat plain git, not federation, as the portability guarantee today.
  • CI is portable YAML over OCI images. A Woodpecker step is an image and a command. The pipeline file is specific to Woodpecker, but the *work* — containers doing builds — is engine-agnostic. Re-expressing pipelines on another runner is mechanical because the steps were never hiding anything.
  • The registry is OCI, full stop. Zot stores standard OCI artifacts. Any registry that implements the distribution spec — another Zot, Harbor, a cloud registry — can receive a mirror of everything. skopeo copy walks the whole namespace. There is no proprietary metadata to strip out first.
  • Identity is OAuth/OIDC. The auth between layers is standard, so swapping any one component does not force re-plumbing the others' trust relationships.

The Long Game: A Delivery Chain You Can Audit in 2035

The delivery chain is decade-horizon infrastructure. The source you commit this year will still be building products long after today's tooling decisions are forgotten, which is why each component's governance matters more than its current feature list. Forgejo under a non-profit with copyleft, Woodpecker under Apache-2.0, Zot under the CNCF — these are project structures chosen to outlast any one company's strategy. That is not nostalgia for self-hosting; it is the long-game pillar applied to the most foundational layer of an engineering org.

None of this is a mandate to rip out GitHub on Monday. Sovereignty is pursued in proportion to your ability to operate it: a team that cannot reliably run Forgejo, Woodpecker, and Zot should not bet production delivery on them yet — it should build that capability deliberately, exactly as a cloud repatriation succeeds or fails on operating-model readiness, not enthusiasm. Staying on a hosted forge with a tested, rehearsed exit plan is itself a sovereign decision; drifting deeper into a bundle because leaving feels hard is not.

When the tj-actions news broke, we spent a day auditing which pipelines could have leaked secrets. The honest answer was: all of them, because we ran third-party actions on shared runners with our credentials in scope. We didn't leave GitHub in a panic — we built the owned chain over a quarter, pinned everything by digest, and now the build environment is something we can describe to an auditor. The point was never to hate GitHub. It was to be able to leave it.
Platform engineering lead, regulated SaaS — anonymized

That is the sovereign delivery chain in one idea: not a rejection of the hosted world, but the standing ability to step out of it on your own terms. Forgejo, Woodpecker, and Zot are three good components, but the architecture is the real product — three owned responsibilities joined only by git, OAuth, and the OCI spec, each replaceable without disturbing the others. Build the chain that way and the next change in someone else's terms of service becomes a memo you read, not an emergency you absorb.

§FAQ/Common questions

Frequently asked

What is the best self-hosted CI/CD alternative to GitHub Actions in 2026?

There is no single product that replaces GitHub Actions, because GitHub bundles three responsibilities — the forge, CI, and the registry — that are better owned as separate, replaceable components. A strong open-source combination is Forgejo for git hosting and code review, Woodpecker CI for pipelines (every step runs in a container, with Docker, Kubernetes, and local backends), and Zot for an OCI-native artifact registry. Forgejo also ships its own GitHub-Actions-style CI (Forgejo Actions) if you prefer a single system. The right answer depends on your team's ability to operate the stack and on which layer's lock-in worries you most; the architectural goal is that each layer speaks a standard protocol so none of them becomes unleaveable.

Is Forgejo Actions compatible with GitHub Actions workflows?

It is familiar, not a guaranteed drop-in. Forgejo Actions uses a YAML syntax modeled on GitHub Actions and runs via forgejo-runner, which is built on the same act engine that executes GitHub workflows locally, so many simple workflows move with little change. But it is not designed for full compatibility: actions that depend on GitHub-specific APIs or context, on the exact ubuntu-latest runner image, or on certain job keys may behave differently, and the default runner container is Debian-based rather than GitHub's image. Treat low-complexity pipelines as a near lift-and-shift, and budget real effort for workflows wired deeply into GitHub-specific features — those are often better rewritten onto a plain-container engine like Woodpecker.

Why use Woodpecker CI instead of Forgejo's built-in Actions?

Both are valid; they optimize for different things. Forgejo Actions keeps everything in one system and eases migration for teams coming from GitHub Actions. Woodpecker is a separate, forge-agnostic engine (a maintained Apache-2.0 fork of Drone CI) where every step is simply a container image plus a command — there is no GitHub-compatibility layer to reason about, the backend can be Docker, Kubernetes, or local, and the same engine works across Forgejo, Gitea, GitHub, and GitLab. Choose Forgejo Actions for the lowest-friction path off GitHub inside one tool; choose Woodpecker when you want an explicit, transparent, multi-forge CI whose unit of work is the OCI container you already understand.

What does Zot give me that Docker Registry or Harbor doesn't?

Zot is a CNCF Sandbox registry that implements the OCI Distribution Spec and little else — a single Go binary with no Docker daemon dependency. Against the bare distribution registry, Zot adds built-in continuous CVE scanning (via Trivy), native handling of non-image artifacts like SBOMs and signatures, search, and upstream sync/mirroring. Against Harbor, Zot trades a large feature surface and multi-service deployment for a minimal, OCI-pure one that is easier to operate and audit. If you want a feature-rich registry with projects, quotas, and replication policies, Harbor fits; if you want the smallest dependable artifact store that treats signatures and SBOMs as first-class OCI objects, Zot fits a sovereign supply chain well.

How do I migrate from GitHub Actions to a self-hosted forge without downtime?

Migrate incrementally and keep GitHub authoritative until the owned chain is proven. Stand up Forgejo, mirror each repository in with git clone --mirror and git push --mirror so all history, branches, and tags come across, then keep the two forges in sync as mirrors during a bake period. Run the new Woodpecker (or Forgejo Actions) pipelines in parallel with your existing GitHub Actions workflows, and cut over per-repository only once the owned pipeline has been green and trusted for long enough. Because git is distributed, every clone is a full backup and the migration is reversible at every step. Use the migration to pin every action and base image by digest and pull them through your own registry, retiring the mutable-tag risk that supply-chain compromises like CVE-2025-30066 exploited.

self-hosted CI/CD GitHub Actions alternativeForgejo Woodpecker CI self-hosted 2026migrate GitHub Actions to WoodpeckerForgejo vs Gitea hard forkZot OCI artifact registry self-hostedself-hosted forge GitHub exit

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.