"AI ships the bugs. AI ships the exploits. Your patch cycle didn't speed up"

Every SMB hits this wall, sooner or later. A due diligence lands, an ISO 27001 kickoff fires up, or an enterprise customer asks the first hard security question — and someone finally opens a Kubernetes manifest and finds a third-party Docker Hub image sitting in production.

The usual answers come in two flavors: Pay for Chainguard or Docker Hardened Images on a team plan (outside the budget of a 30-person shop). Or shrug and promise to "look at it next quarter."

There's a third option. Real work, real payoff — and the artifacts an auditor will actually sign off on. This is what we run at THEKROLL, with Gokapi as the worked example here. Our internal FOSS tool for curl-from-the-CLI file shuffling, for the spot where scp and rsync don't fit and a Vault setup is overkill.

Title image

Why This Exists: transfer.sh, Abandoned

A confession. We didn't start with Gokapi. We started the evaluation with dutchcoders/transfer.sh — the canonical self-hosted file-sharing tool in the Go ecosystem, and frankly the better fit for what we needed. Upload, download, no UI, no auth dance, no plugin marketplace. Minimal feature set = minimal attack surface. That's the right shape of tool.

That's also the trap. Most teams would now do what their AI advisor of choice tells them: "transfer.sh, super popular, here's the docker-compose, ship it." That advice would be fatal.

First mirror: green. Second build: blocked. 4 CRITICAL and 16 HIGH in the Go dependencies. The CVEs were months old. The upstream repo: no commits since 2023. The project had quietly died, and Docker Hub was happily serving the corpse.

We looked for a maintained fork. There isn't one — at least not at a level of activity we'd run in production. So we switched to Forceu/Gokapi, an actively-maintained Firefox-Send-style alternative in Go. Gokapi is more tool than we strictly need, but rolling our own minimal transfer.sh-style replacement isn't on the cards. Building one with AI is a weekend. Maintaining it is years. Two different jobs.

This is the failure mode that makes casual docker pull dangerous in production. A widely-used tool drifts from "actively maintained" to "effectively abandoned" with no announcement. Docker Hub keeps serving it. Your cluster keeps pulling it. CVEs accumulate invisibly.

The point isn't how many CVEs transfer.sh accumulated. The point is that we look. Every time. The pipeline below is what makes "every time" sustainable.

The Three Jobs: Build, Scan, Harden

Three jobs hold this together. Skip one and you've built theatre, not a control:

  1. Build it yourself. No Docker Hub pulls in production. You own the registry and the build logs.
  2. Scan and gate. Every build ships an SBOM, a scanner report, and a human-readable diff against upstream. CVEs block the push.
  3. Harden the base. No Alpine or Debian-slim and their monthly CVE churn. Rebase onto a minimal base — distroless by default — so your runtime attack surface shrinks to roughly what your binary actually uses.

All three: a supply-chain control story that survives DD. Skip the third and Trivy reports become a monthly waiver-filing ritual against upstream Alpine CVEs. That's how "we scan every image" becomes "we scan and ignore every image."

10 AM, Second Coffee, Slowly Coming Online

10 AM. Second coffee. Slowly coming online. The pipeline finished while we were asleep, and one of two notifications is waiting in the inbox (GitHub-native, Slack, Teams, email — your call):

(A) "Gokapi v2.2.4 → v2.2.5. Trivy clean, SBOM updated. PR ready." Skim the diff, approve, merge. ArgoCD picks up the new digest. Done.

(B) "Gokapi v2.2.5 build BLOCKED. HIGH CVE in a dependency. Image NOT pushed. Issue opened." Open the issue, read the Trivy output, decide: wait for upstream fix, or add a documented waiver to .trivyignore.

Either way, nothing unreviewed lands in your cluster. The build either waits clean or blocks with documentation. That's the point.

What It Catches. What It Doesn't.

It catches registry-side image tampering (you pull from your own GHCR), missing CVE visibility at deploy time (blocked at push), the base-image CVE backlog (rebased to minimal), Docker Hub outages (no hard dependency), and — the one we learned the hard way — abandoned upstreams that quietly stopped shipping patches. Plus the artifacts an auditor wants: SBOMs, scan reports, diff reviews per upstream bump.

It catches CVEs published after release. The main build job scans at build time — good for what exists today. A nightly rescan job hits the last-built image against the current Trivy database without rebuilding. CVE lands tomorrow against a library inside yesterday's deployed image? The rescan surfaces it and auto-files an issue. You decide manually: wait for upstream, or roll a dependency pin into your override. The pipeline never silently ships new digests. This is the gap release-driven pipelines have by default.

It doesn't catch a sophisticated source-code backdoor from a compromised upstream maintainer — the xz scenario. No scanner catches that cleanly. Anyone selling you otherwise is overclaiming. What the pipeline gives you for that threat class is a documented diff-review artifact at every upstream bump, reviewed by a human before deployment. Same control a well-run enterprise applies. Defensible in an audit, which is more than most SMBs have today.

The whole problem stays invisible until the first DD or enterprise security questionnaire drags it into the open.

The 5-Minute Setup: Fork, Configure, Ship

Here's how you stand up your own mirror. Four steps.

1. Fork the template. Go to https://github.com/THEKROLL-LTD/oss-mirror-build, click "Use this template". You now own a copy.

2. Set two env vars. Open .github/workflows/build.yml, edit the env block at the top:

// yaml
env:
  UPSTREAM_REPO: "Forceu/Gokapi"
  IMAGE_NAME: "ghcr.io/your-org/gokapi"

3. Drop in your hardened Dockerfile. Save it at dockerfiles/Dockerfile.override. The Gokapi-on-distroless example sits in the next section — copy it, adjust for your upstream.

4. Commit and push. Nightly cron handles the rest.

Done. From here on, the workflow runs every night and does this:

  • Check for a new upstream tag. Nothing changed? Exit.
  • Clone upstream at the new tag. Unchanged source — zero fork maintenance.
  • Apply your Dockerfile override. If dockerfiles/Dockerfile.override exists, it replaces upstream's. This is how you rebase onto distroless.
  • Emit a diff-review artifact. Commit log and full patch between the last built tag and the new one. 90-day retention.
  • Build the image — once. Loaded locally. No double-build.
  • Scan with Trivy. SARIF for the Security tab, CycloneDX SBOM alongside. CRITICAL or HIGH findings with an available fix block the push.
  • Block: no push, auto-filed issue. Pass: push to GHCR and open a PR against main with the new digest pin. You review, approve, merge. Flux or ArgoCD sees the updated image-pin.yml and rolls out the exact image CI scanned.

Can Claude Just Do This for You?

Reasonable question. The model side is easy. Claude Code with Opus 4.7 or Codex with GPT-5.5 will spit out a working override in a few minutes. Dockerfile, build flags, first green run — handled.

The real question is whether you can drive it. Pointing the agent at a repo is the easy half; knowing what you actually want, knowing what "right" looks like, knowing when the model is confidently wrong — that's the other half. Sure, on a complex app where you don't know how the code is structured and can't articulate the requirement, you'll cheerfully shoot yourself in the foot — and the agent will hand you a beautifully scanned, fully reproducible disaster. But for most overrides? Genuinely easy. You point, it ships, you move on.

Non-negotiable, though: you have to read what the agent shipped, understand it, and sign off on it. Not skim. Understand. The agent doesn't know if a CVE is exploitable from your topology. It doesn't know your waiver policy. It will not push back on a 400-line diff on your behalf. That part stays human, every time.

Save the scaffolding time. Spend it on review.

That's the howto. Five-minute setup, nightly automation, judgment in human hands.

v2.2.4 Ships With 8 HIGH

Run Trivy against the upstream Gokapi v2.2.4 image. No override, no rebuild — just trivy image forceu/gokapi:v2.2.4. What comes back:

8 HIGH findings. Sitting in the binary you were about to deploy. Most teams never scan, never know.

Now rebuild with our override — distroless/static:nonroot runtime, Go 1.26.2 build stage — rescan.

the result: Only 1 HIGH left.

Seven of eight, gone. Same upstream code, same release tag, no Gokapi-side patches. Just a different base layer and a fresher toolchain. The findings that disappeared were never about Gokapi in the first place — they were about what Gokapi was being shipped on top of.

The one HIGH that remained sat in a code path our deployment doesn't exercise. We assessed it, filed a documented waiver, shipped. Pipeline surfaced the finding, a human made the call, the rationale is on record — exactly how the gate is supposed to work when "block forever" isn't an option.

Github Issue Screenshot
The actual issue the bot filed for that one HIGH: CVE-2026–34986 in go-jose/v4 — Go JOSE DoS via crafted input, fix in v4.1.4 (we're at v4.1.3 transitively). Build blocked, image not pushed, structured findings plus cve / blocked / security labels in the tracker. Trivy scans the built binary, not go.mod — the report covers what actually ships, after the linker has trimmed unused paths. From here it's a human call: wait, fix, waive. We waived.

Now look at why.

The Override: 7 of 8, Gone

Gokapi's upstream Dockerfile uses a clean Go-Alpine build stage, then a runtime layer of FROM alpine:3.19 plus su-exec, tini, ca-certificates, curl, tzdata, and a shell entrypoint script. Our override swaps the runtime stage for gcr.io/distroless/static:nonroot:

// dockerfile
FROM golang:1.26.2-alpine AS build
WORKDIR /src
RUN apk add --no-cache git
COPY . .
RUN go generate ./... && \
    CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
    -o /out/gokapi github.com/forceu/gokapi/cmd/gokapi

FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/gokapi /gokapi
USER nonroot:nonroot
EXPOSE 53842
ENTRYPOINT ["/gokapi"]

A few things vanish on purpose. su-exec and tini are workarounds for raw docker run — Kubernetes handles signal forwarding and privilege-dropping natively, and Go's stdlib handles SIGTERM cleanly on its own. The upstream entrypoint script mostly switches UIDs at container start; distroless's :nonroot variant runs as UID 65532 out of the box, so the script's job is already done. curl exists for raw-Docker health checks; Kubernetes probes hit HTTP endpoints directly.

Distroless is Google's minimal-base-images project, Apache-2.0 since 2017. The static:nonroot variant clocks in around 2 MB. CA certificates, /etc/passwd, tzdata. No shell, no package manager, no wget, no sh.

Mechanics, in detail

Where the seven went. Six split cleanly by mechanism — 2 in Alpine-layer packages (musl, musl-utils), gone with the base swap; 4 Go stdlib CVEs, gone because the stdlib is linked into the binary and the toolchain bump rebuilt them. The seventh was application-layer and cleared upstream between our pre- and post-override scans. The 1 HIGH that remained also sat application-layer, in a code path our deployment doesn't exercise — we waived it, documented the call, shipped. The honest split: the override clears the base-image backlog and the toolchain backlog in one pass — not the application-layer backlog. That part stays a manual judgment.

And those 2 Alpine findings are a lower bound. The v2.2.4 release Docker Hub currently serves pins FROM alpine:3.19, EOL since late 2025. Trivy says so mid-scan: "This OS version is no longer supported by the distribution — vulnerability detection may be insufficient." Backported fixes don't exist for that layer anymore. Scanners only find what's in their database. Upstream master is already on alpine:3.23, no release yet — and the gap between a CVE landing, an EOL kicking in, and a release catching up is exactly where users without a pipeline like this are blind. The override cuts the dependency: we pick our own base, on our own cadence.

A subtlety on the build stage. golang:1.26.2-alpine gets discarded after COPY --from=build, so you'd assume its CVEs don't matter. Mostly true. One exception: the Go toolchain links part of itself — the standard library — into the resulting binary. That's how the four Go stdlib CVEs vanished. Not because Gokapi fixed anything. Because we rebuilt on a newer Go. The builder Go release is a runtime-security lever, not just builder hygiene. Renovate tracks new Go releases automatically; every stdlib patch becomes a reviewable bump PR, treated identically to an upstream app bump.

Pin both FROM lines to digests. The example above uses tags for readability. Production overrides should be golang:1.26.2-alpine@sha256:... and distroless/static:nonroot@sha256:.... Tags are mutable. Digests aren't. If Google repoints distroless/static:nonroot overnight — even for benign reasons like a CA-certificate refresh — a tag-based build picks it up silently. A digest-pinned build doesn't move until you review the change. The "latest is better, distroless gets maintained" line gets it backwards: the maintenance is real, but you want it reviewable, not silent. Renovate with pinDigests: true handles both halves — pins on the first PR it opens against your override, then files a new reviewable PR every time a digest changes. Same mechanism for builder and runtime.

Why distroless and not Docker Hardened Images? DHI is good. It also sits inside an evolving vendor programme whose free-tier terms have been redrawn more than once. Distroless is Apache-2.0, Google-maintained, not going anywhere. For a post that argues against vendor lock-in, distroless is the consistent call. Chainguard is the commercial tier above both — pick it when your compliance regime explicitly demands vendor support and the budget justifies it.

The catch: distroless has no shell. kubectl exec -it pod -- sh doesn't work. For Kubernetes debugging, use kubectl debug with an ephemeral container, or maintain a :debug variant for non-production. Cultural shift, not a difficult one. State it upfront.

And: the override is a one-time job per image, but effort varies with what upstream ships. Gokapi sits at the higher end — shell entrypoint, su-exec, tini all need translating into orchestrator-level concerns — so budget 30 to 60 minutes. Upstreams that already ship a clean USER nonroot directive and a direct binary entrypoint are closer to a 10-minute FROM swap. Apps with CGO or native runtime dependencies land in between. The template ships a Gokapi override as reference; PRs with overrides for other upstreams welcome.

What the Auditor Actually Opens

For any upstream bump in the last 90 days:

  • Diff-review artifact — commits landed, files changed, reviewer name from the merge commit, timestamp.
  • Trivy scanner report — SARIF in the Security tab, timestamped, every build.
  • CycloneDX SBOM — every package, every version, exactly as shipped. This is what DORA, SOC 2, and increasingly ISO 27001 ask for by name.
  • Digest-pin PR history — every production image rollout went through a reviewed, approved, merged PR. Reviewer name, timestamp, scan evidence linked. Stronger than a bot commit.
  • Documented base-image choicedockerfile_source: override on every pinned build, override file under version control, reviewable in a PR.
  • Blocked builds with auto-filed issues — when a CVE hit a version before it reached production. Idempotent across re-runs of the same tag, so a flaky workflow doesn't spam your tracker. The control, working — see the issue screenshot earlier in this post for what one looks like in the wild.

A documented supply-chain control with artifacts that don't require the auditor to take your word. Not as polished as Chainguard. Polished enough to pass.

This is not a substitute for Dependabot on your own code. Dependabot lives upstream of this pipeline and scans the repos you write. What this pipeline gives you is the missing piece for the third-party OSS container layer — the one Dependabot can't see because you don't own the source.

When Not to Bother

Three cases where a different answer wins. Large, well-audited upstreams — official Postgres, Nginx, Redis — give you no marginal value from building yourself. Pin the digest, proxy through a cache, move on. Regulated or federal workloads that need minimal-by-construction SBOMs and vendor support? Pay for Chainguard or DHI. Upstreams that release more than weekly break the human-in-the-loop review model. Automate harder, or reconsider the dependency.

Everything in between — a handful of OSS services you self-host because you want data sovereignty, and need to defend that choice in front of a buyer or an auditor — the template works. It's what we run.

A note on licenses. Mirror images inherit upstream's license. Gokapi is AGPL-3.0, so what we publish to GHCR is AGPL-3.0 — separate from this template's own Apache-2.0 license. For self-hosted operators this is mostly a non-issue: AGPL §13 requires that network users can reach the source, which is trivially the upstream repo, because the mirror doesn't change the source. If a permissive license matters for your deployment, root-gg/plik is MIT-licensed and covers a similar file-transfer niche. Either way: read upstream's LICENSE before publishing mirrored images to a public registry.


Template (fork for your own pipeline): https://github.com/THEKROLL-LTD/oss-mirror-build

Live example (our Gokapi mirror, nightly builds, audit bundles retained 90 days): https://github.com/THEKROLL-LTD/mirror-gokapi

PRs with Dockerfile overrides for other upstreams welcome. The live mirror has no SLA — if you need production-grade assurance, fork the template.

If your team is stuck between "we can't ship Docker Hub images" and "we can't afford Chainguard", talk to us. We've shipped the override, and we've fixed the parts that broke.