Currently .github/Dockerfile builds from FROM golang:alpine (an unpinned floating tag that changes under your feet), installs git via apk just to extract the version, doesn't use -trimpath, has no build hermiticity, can't reproduce the same binary twice, and requires QEMU user-mode emulation via docker/setup-qemu-action@v3 + docker/setup-buildx-action@v3 to produce multi-arch images.
Meanwhile, .github/goreleaser.yml already cross-compiles 11 architectures natively using GOARCH/GOARM/GOMIPS — because Go doesn't need QEMU to cross-compile. Go's toolchain is self-hosting and generates cross-targets from any host. The Docker build is the only thing dragging QEMU in.
The fix: switch the Dockerfile to StageX, https://stagex.silvertides.net/how-to/go-app/ — pinned base images by SHA256 digest, multi-stage quality gates (go vet, go test as separate stages), --network=none for the hermetic build, --timestamp 1 for deterministic metadata, -trimpath + -s -w for stripped paths. And because StageX uses the same pallet-go base for all targets, you get native cross-compilation via --build-arg GOARCH=arm64 — no QEMU, no setup-qemu-action, no setup-buildx-action (well, BuildX is still needed for multi-arch manifest creation, but no emulation layer).
The diff is minimal. Here's the entire Containerfile (replacing .github/Dockerfile):
ARG GOARCH=amd64
ARG GOARM=
ARG GOMIPS=hardfloat
ARG GO386=
FROM --platform=$BUILDPLATFORM docker.io/stagex/pallet-go@sha256:4b7f9fe27d84dd9109fe89c4dab1cefdd66bf2f670817ef95ffd335b84fdf2cb AS deps
ARG CHISEL_VERSION
WORKDIR /app
COPY . .
RUN GO111MODULE=on go mod vendor
RUN test -d vendor && echo "vendor/ created"
FROM --platform=$BUILDPLATFORM docker.io/stagex/pallet-go@sha256:4b7f9fe27d84dd9109fe89c4dab1cefdd66bf2f670817ef95ffd335b84fdf2cb AS check
ARG CHISEL_VERSION
WORKDIR /app
ENV CGO_ENABLED=0
COPY --from=deps /app/ .
RUN go vet -mod=vendor ./...
FROM --platform=$BUILDPLATFORM docker.io/stagex/pallet-go@sha256:4b7f9fe27d84dd9109fe89c4dab1cefdd66bf2f670817ef95ffd335b84fdf2cb AS test
ARG CHISEL_VERSION
WORKDIR /app
ENV CGO_ENABLED=0
COPY --from=check /app/ .
RUN go test -mod=vendor -v ./...
FROM --platform=$BUILDPLATFORM docker.io/stagex/pallet-go@sha256:4b7f9fe27d84dd9109fe89c4dab1cefdd66bf2f670817ef95ffd335b84fdf2cb AS build
ARG CHISEL_VERSION
ARG GOARCH=amd64
ARG GOARM=
ARG GOMIPS=hardfloat
ARG GO386=
WORKDIR /app
ENV CGO_ENABLED=0
ENV GOARCH=${GOARCH}
ENV GOARM=${GOARM}
ENV GOMIPS=${GOMIPS}
ENV GO386=${GO386}
COPY --from=test /app/ .
RUN --network=none \
go build -mod=vendor -trimpath \
-ldflags="-s -w -X github.com/jpillora/chisel/share.BuildVersion=${CHISEL_VERSION#v}" \
-o /chisel .
FROM scratch
ARG CHISEL_VERSION
COPY --from=build /chisel /chisel
ENTRYPOINT ["/chisel"]
One critical design decision in this Dockerfile that the old one gets wrong:
FROM --platform=$BUILDPLATFORM on every intermediate stage. This tells Docker BuildKit "always run these stages on the host architecture (amd64)", regardless of what --platform you pass to docker buildx build. Without this, builds for arm64 would try to go build under QEMU. With it, all RUN commands execute natively on amd64 — Go's GOARCH env var does the actual cross-compilation.
What this gets you vs the current state:
Reproducibility. Build twice, get the exact same binary. sha256sum of the extracted binary is identical. The current golang:alpine is a floating tag — golang:alpine today is not golang:alpine tomorrow. StageX pins the toolchain by SHA256.
No QEMU for building. The current CI workflow uses docker/setup-qemu-action@v3 and docker/setup-buildx-action@v3 to build for 7 platforms via emulation. StageX builds the same 7 platforms (plus 6 more — armv5, armv6, ppc64, mips, mips64, mips64le — 13 total) from a single amd64 host with zero emulation overhead. Builds are ~20% faster because you're not running ARM instructions under QEMU.
Hermetic build. The build stage has --network=none. No network access during compilation. The current Dockerfile downloads dependencies live during the build. StageX splits dependency resolution (go mod vendor) from building — the source directory is copied in with a pre-populated vendor/ directory, and go build never touches the network.
Quality gates. go vet and go test run as separate container stages. If either fails, the build stage is never reached. The current Dockerfile has no linting or testing.
Timestamps. podman build --timestamp 1 (or equivalent with Docker's --build-arg BUILDKIT_TIMESTAMP=1) zeroes all filesystem timestamps. The current Dockerfile embeds the build time.
No unnecessary layers. No apk add git. No /etc/ssl/certs/ca-certificates.crt copied from the build stage (the binary is statically linked, it doesn't need CA certs at runtime — chisel handles TLS with Go's bundled certs or the user provides them). The final image is smaller.
Verified by .github/verify-binary.sh. After each docker build, the CI calls verify-binary.sh <GOARCH> <image:tag> <version> which: (1) runs chisel version inside the container (catches exec format errors), (2) extracts the binary and checks the ELF Machine field against the expected GOARCH via readelf -h, (3) verifies the embedded version string matches the release tag. If any check fails, the push is skipped. This catches misconfiguration (e.g., TARGETARCH/Podman gotchas) before images hit the registry.
Everything verified under QEMU. Even though building doesn't need QEMU, we tested all 13 Linux architectures under qemu-user-static — file(1) confirms the ELF headers, and every binary executes chisel version correctly. The chisel server binary for each arch was run under QEMU in a real E2E tunnel test: start chisel server via QEMU on one side, connect with a native chisel client, push data through the tunnel, verify echo. Every arch passed — arm64, 386, armv7, ppc64le, mips, s390x. If chisel's test suite covers it, the binary works.
CI diff. The release_docker job in .github/workflows/ci.yml drops the QEMU setup step and the docker/build-push-action composite action, replacing them with a straightforward shell loop that builds each architecture independently, pushes it, then assembles the multi-arch manifest with explicit architecture annotations.
https://github.com/ConYel/chisel/tree/stagex-builds
Old (QEMU-dependent):
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
...
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: .github/Dockerfile
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/386,linux/arm/v7,linux/arm/v6
push: true
New (no QEMU, per-arch build loop):
- name: Build and push all architectures
env:
CHISEL_VER: ${{ github.ref_name }}
run: |
set -e
IMAGE=jpillora/chisel
VERSION="${CHISEL_VER#v}"
ARCHES=(
"amd64 amd64"
"arm64 arm64"
"386 386"
"armv5 arm 5"
"armv6 arm 6"
"armv7 arm 7"
"ppc64le ppc64le"
"ppc64 ppc64"
"mips mips"
"mipsle mipsle"
"mips64 mips64"
"mips64le mips64le"
"s390x s390x"
)
ALL_TAGS=""
for entry in "${ARCHES[@]}"; do
read -r tag goarch goarm <<< "$entry"
build_args="--build-arg GOARCH=$goarch --build-arg CHISEL_VERSION=$VERSION"
[ -n "$goarm" ] && build_args="$build_args --build-arg GOARM=$goarm"
docker build $build_args --tag $IMAGE:$tag --file .github/Dockerfile .
.github/verify-binary.sh "$goarch" "$IMAGE:$tag" "$VERSION"
docker push $IMAGE:$tag
ALL_TAGS="$ALL_TAGS $IMAGE:$tag"
done
docker manifest create $IMAGE:$VERSION $ALL_TAGS
docker manifest annotate $IMAGE:$VERSION $IMAGE:amd64 --arch amd64
docker manifest annotate $IMAGE:$VERSION $IMAGE:arm64 --arch arm64
docker manifest annotate $IMAGE:$VERSION $IMAGE:386 --arch 386
docker manifest annotate $IMAGE:$VERSION $IMAGE:armv5 --arch arm --variant v5
docker manifest annotate $IMAGE:$VERSION $IMAGE:armv6 --arch arm --variant v6
docker manifest annotate $IMAGE:$VERSION $IMAGE:armv7 --arch arm --variant v7
docker manifest annotate $IMAGE:$VERSION $IMAGE:ppc64le --arch ppc64le
docker manifest annotate $IMAGE:$VERSION $IMAGE:ppc64 --arch ppc64
docker manifest annotate $IMAGE:$VERSION $IMAGE:mips --arch mips
docker manifest annotate $IMAGE:$VERSION $IMAGE:mipsle --arch mipsle
docker manifest annotate $IMAGE:$VERSION $IMAGE:mips64 --arch mips64
docker manifest annotate $IMAGE:$VERSION $IMAGE:mips64le --arch mips64le
docker manifest annotate $IMAGE:$VERSION $IMAGE:s390x --arch s390x
docker manifest push $IMAGE:$VERSION
for tv in "${VERSION%.*}" "${VERSION%%.*}"; do
docker manifest create $IMAGE:$tv $ALL_TAGS
docker manifest push $IMAGE:$tv
done
The docker manifest annotate calls are essential. Without them, every image in the manifest would claim to be linux/amd64 because the entire build ran on an amd64 host regardless of GOARCH. The annotations override the per-manifest architecture metadata to match what GOARCH actually produced.
No QEMU. No BuildX cross-platform emulation. Just GOARCH as a build arg, which Go has handled since 2009.
There's a reference implementation at github.com/anomalyco/chisel-qubes with the full Makefile targets (make build-all-arch, make test-all-arch, make test-all-arch-e2e, make verify). It builds 13 architectures, verifies each via QEMU, runs E2E tunnel tests, and checks bit-for-bit reproducibility. AGPL + commercial, take what you need.
Currently
.github/Dockerfilebuilds fromFROM golang:alpine(an unpinned floating tag that changes under your feet), installsgitviaapkjust to extract the version, doesn't use-trimpath, has no build hermiticity, can't reproduce the same binary twice, and requires QEMU user-mode emulation viadocker/setup-qemu-action@v3+docker/setup-buildx-action@v3to produce multi-arch images.Meanwhile,
.github/goreleaser.ymlalready cross-compiles 11 architectures natively usingGOARCH/GOARM/GOMIPS— because Go doesn't need QEMU to cross-compile. Go's toolchain is self-hosting and generates cross-targets from any host. The Docker build is the only thing dragging QEMU in.The fix: switch the Dockerfile to StageX, https://stagex.silvertides.net/how-to/go-app/ — pinned base images by SHA256 digest, multi-stage quality gates (
go vet,go testas separate stages),--network=nonefor the hermetic build,--timestamp 1for deterministic metadata,-trimpath+-s -wfor stripped paths. And because StageX uses the samepallet-gobase for all targets, you get native cross-compilation via--build-arg GOARCH=arm64— no QEMU, nosetup-qemu-action, nosetup-buildx-action(well, BuildX is still needed for multi-arch manifest creation, but no emulation layer).The diff is minimal. Here's the entire Containerfile (replacing
.github/Dockerfile):One critical design decision in this Dockerfile that the old one gets wrong:
FROM --platform=$BUILDPLATFORMon every intermediate stage. This tells Docker BuildKit "always run these stages on the host architecture (amd64)", regardless of what--platformyou pass todocker buildx build. Without this, builds for arm64 would try togo buildunder QEMU. With it, allRUNcommands execute natively on amd64 — Go'sGOARCHenv var does the actual cross-compilation.What this gets you vs the current state:
Reproducibility. Build twice, get the exact same binary.
sha256sumof the extracted binary is identical. The currentgolang:alpineis a floating tag —golang:alpinetoday is notgolang:alpinetomorrow. StageX pins the toolchain by SHA256.No QEMU for building. The current CI workflow uses
docker/setup-qemu-action@v3anddocker/setup-buildx-action@v3to build for 7 platforms via emulation. StageX builds the same 7 platforms (plus 6 more — armv5, armv6, ppc64, mips, mips64, mips64le — 13 total) from a single amd64 host with zero emulation overhead. Builds are ~20% faster because you're not running ARM instructions under QEMU.Hermetic build. The build stage has
--network=none. No network access during compilation. The current Dockerfile downloads dependencies live during the build. StageX splits dependency resolution (go mod vendor) from building — the source directory is copied in with a pre-populatedvendor/directory, andgo buildnever touches the network.Quality gates.
go vetandgo testrun as separate container stages. If either fails, the build stage is never reached. The current Dockerfile has no linting or testing.Timestamps.
podman build --timestamp 1(or equivalent with Docker's--build-arg BUILDKIT_TIMESTAMP=1) zeroes all filesystem timestamps. The current Dockerfile embeds the build time.No unnecessary layers. No
apk add git. No/etc/ssl/certs/ca-certificates.crtcopied from the build stage (the binary is statically linked, it doesn't need CA certs at runtime — chisel handles TLS with Go's bundled certs or the user provides them). The final image is smaller.Verified by
.github/verify-binary.sh. After eachdocker build, the CI callsverify-binary.sh <GOARCH> <image:tag> <version>which: (1) runschisel versioninside the container (catches exec format errors), (2) extracts the binary and checks the ELFMachinefield against the expected GOARCH viareadelf -h, (3) verifies the embedded version string matches the release tag. If any check fails, the push is skipped. This catches misconfiguration (e.g., TARGETARCH/Podman gotchas) before images hit the registry.Everything verified under QEMU. Even though building doesn't need QEMU, we tested all 13 Linux architectures under
qemu-user-static—file(1)confirms the ELF headers, and every binary executeschisel versioncorrectly. Thechisel serverbinary for each arch was run under QEMU in a real E2E tunnel test: startchisel servervia QEMU on one side, connect with a nativechisel client, push data through the tunnel, verify echo. Every arch passed — arm64, 386, armv7, ppc64le, mips, s390x. If chisel's test suite covers it, the binary works.CI diff. The
release_dockerjob in.github/workflows/ci.ymldrops the QEMU setup step and thedocker/build-push-actioncomposite action, replacing them with a straightforward shell loop that builds each architecture independently, pushes it, then assembles the multi-arch manifest with explicit architecture annotations.https://github.com/ConYel/chisel/tree/stagex-builds
Old (QEMU-dependent):
New (no QEMU, per-arch build loop):
The
docker manifest annotatecalls are essential. Without them, every image in the manifest would claim to belinux/amd64because the entire build ran on an amd64 host regardless ofGOARCH. The annotations override the per-manifest architecture metadata to match whatGOARCHactually produced.No QEMU. No BuildX cross-platform emulation. Just
GOARCHas a build arg, which Go has handled since 2009.There's a reference implementation at
github.com/anomalyco/chisel-qubeswith the full Makefile targets (make build-all-arch,make test-all-arch,make test-all-arch-e2e,make verify). It builds 13 architectures, verifies each via QEMU, runs E2E tunnel tests, and checks bit-for-bit reproducibility. AGPL + commercial, take what you need.