Skip to content
Go back

ko vs Jib vs Buildpacks

By SumGuy 10 min read
ko vs Jib vs Buildpacks

Nobody Wants to Write Another Dockerfile

You know the drill. New service, new Dockerfile. Copy the boilerplate from the last one, swap the binary name, forget to add the non-root user, get flagged in the security scan six months later, finally fix it when someone files a Jira ticket. Repeat forever.

Here’s the thing: for Go binaries, Java JARs, and most common app stacks, you’re not writing a Dockerfile because you need one. You’re writing it because you didn’t know there was another option.

There are three tools that have quietly solved this for their respective ecosystems: ko (Google, Go-native), Jib (Google, Java-native), and Buildpacks (CNCF/Paketo, everything else). Each one takes your source code or compiled output and produces a proper OCI image — no Dockerfile, no Docker daemon in some cases, no COPY . . madness.

Let’s go through them properly.


ko: For Go, This Is Just the Right Way

ko came out of Google’s internal tooling for shipping Kubernetes controllers. The idea is simple: if you’re building a Go binary, your container image is just that binary plus a base layer. Why do you need a Dockerfile for that?

You don’t.

Terminal window
# Install ko
go install github.com/ko-build/ko@latest
# Build and push to your registry
export KO_DOCKER_REPO=ghcr.io/yourorg
ko build ./cmd/myapp

That’s it. ko runs go build, wraps the output in a distroless base image, and pushes directly to your registry. No Dockerfile. No Docker daemon. No build context being zipped up and sent somewhere.

What ko Actually Produces

The default base is gcr.io/distroless/static:nonroot — a ~2MB layer with no shell, no package manager, no attack surface. Your binary sits on top. Total image size for a typical Go service: 10–20MB instead of 200MB+ from a naive golang:alpine build.

You can override the base if you need one with glibc or certs:

ko.yaml
defaultBaseImage: gcr.io/distroless/base:nonroot

Reproducible Builds

ko produces reproducible images by default. Give it the same source, same Go version, same base digest — you get the same image digest back. This matters for SLSA provenance, for cache hits in your registry, for not triggering unnecessary rollouts in GitOps pipelines.

Multi-Arch

Terminal window
ko build --platform=linux/amd64,linux/arm64 ./cmd/myapp

Go cross-compilation is trivially fast. ko leverages that. Building multi-arch with a Dockerfile means QEMU or native builders and prayer. With ko it’s a flag.

CI Integration

.github/workflows/build.yml
- name: Build and push image
env:
KO_DOCKER_REPO: ghcr.io/${{ github.repository_owner }}
run: |
go install github.com/ko-build/ko@latest
ko build --tags ${{ github.sha }} ./cmd/myapp

ko respects COSIGN_EXPERIMENTAL and integrates with keyless signing out of the box. For Go services in a supply-chain-conscious shop, this is hard to beat.

ko Verdict

If you ship Go binaries, stop writing Dockerfiles for them. ko is faster, produces smaller images, and gives you reproducibility for free. The only time you’d reach for a Dockerfile instead is if you have CGO deps that need specific system libraries, or if you need an init system/multiple processes in the same container (which you probably shouldn’t have anyway).


Jib: Java Containers Without the JAR-Packing Misery

Java Dockerfiles are a particular kind of suffering. The naive version:

FROM eclipse-temurin:21-jre
COPY target/myapp.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Every build, every time, that entire JAR gets copied into a new layer. Your 400MB uber-JAR becomes a 400MB image layer on every code change. CI caches nothing. Your registry fills up. Your deploy times are embarrassing.

Jib solves this by understanding what’s actually in the JAR. It splits your image into layers: JVM dependencies (rarely changes), your app’s third-party deps (changes on library updates), resources (changes occasionally), and your compiled classes (changes on every build). Now CI can cache the fat layers and only push the thin class layer on most builds.

Maven Setup

pom.xml
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.4.3</version>
<configuration>
<to>
<image>ghcr.io/yourorg/myapp:${project.version}</image>
</to>
<container>
<jvmFlags>
<jvmFlag>-XX:+UseContainerSupport</jvmFlag>
<jvmFlag>-XX:MaxRAMPercentage=75.0</jvmFlag>
</jvmFlags>
<mainClass>com.yourorg.MyApp</mainClass>
</container>
</configuration>
</plugin>
Terminal window
# Build and push directly (no Docker daemon needed)
./mvnw jib:build
# Build to local Docker daemon for local testing
./mvnw jib:dockerBuild

Gradle Setup

build.gradle.kts
plugins {
id("com.google.cloud.tools.jib") version "3.4.3"
}
jib {
to {
image = "ghcr.io/yourorg/myapp:${version}"
}
container {
jvmFlags = listOf("-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0")
mainClass = "com.yourorg.MyApp"
}
}
Terminal window
./gradlew jib

Base Image Choices

Jib defaults to eclipse-temurin which is reasonable but not small. For production:

pom.xml (jib config section)
<from>
<image>gcr.io/distroless/java21-debian12:nonroot</image>
</from>

Distroless Java images are around 200MB (the JVM isn’t tiny) but they drop the shell, the package manager, and the surface area for shell injection attacks. Worth it.

Reproducibility

Jib produces reproducible images when you pin your base image digest and your source doesn’t have timestamp noise. It also sets the image creation timestamp to epoch zero by default — which means the layer digest is stable across builds with identical content. Your CI won’t push a new image layer just because the clock ticked.

Multi-Arch

Jib supports multi-arch but requires explicit configuration for each platform. It’s not as smooth as ko:

pom.xml
<configuration>
<from>
<platforms>
<platform>
<os>linux</os>
<architecture>amd64</architecture>
</platform>
<platform>
<os>linux</os>
<architecture>arm64</architecture>
</platform>
</platforms>
</from>
</configuration>

No Docker Daemon Required

Like ko, Jib talks directly to the registry API. This matters in CI environments where you don’t want to run Docker-in-Docker, or where DinD is a security concern. It also makes builds faster — no daemon startup, no build context packaging.

Jib Verdict

If you’re shipping Spring Boot, Quarkus, or any standard Java/Kotlin service, Jib is the right tool. The layer-splitting alone will cut your CI cache misses significantly. Pair it with distroless base images and you’ve got a production-ready pipeline that requires zero Dockerfile maintenance. The main friction is that it’s a build plugin, not a standalone tool, so it’s more opinionated about living inside Maven/Gradle.


Buildpacks: The One Tool to Rule Them All (at a Cost)

Cloud Native Buildpacks (CNB) is the CNCF spec. Paketo Buildpacks is the implementation most people use. Heroku pioneered the concept a decade ago. The pitch: drop your source code in, get a runnable container image out, regardless of what language you wrote it in.

Terminal window
# Install the pack CLI
brew install buildpacks/tap/pack # or grab the binary from github.com/buildpacks/pack/releases
# Build a Node app
pack build myapp --builder paketobuildpacks/builder-jammy-full
# Build a Python app
pack build myapp --builder paketobuildpacks/builder-jammy-full
# Same command. Different app. It just works.

The builder detects your stack — looks for package.json, requirements.txt, go.mod, pom.xml, Gemfile, etc. — and runs the appropriate buildpack. No configuration required for standard setups.

What’s Inside a Buildpack Image

Buildpack images have a specific layer structure: a run image (the OS base), a launch layer (runtime dependencies), and app layers. The run image for Paketo’s jammy-full is Ubuntu 22.04, which is ~100MB before your app even loads. That’s the price of being language-agnostic — you’re carrying an OS.

For comparison: ko distroless is 2MB, Jib distroless is ~200MB (JVM), buildpacks full is 300–600MB+ depending on what’s detected.

You can use smaller builders:

Terminal window
# Smaller, less language support
pack build myapp --builder paketobuildpacks/builder-jammy-tiny
# Check what buildpacks are in a builder
pack inspect-builder paketobuildpacks/builder-jammy-full

SBOM Out of the Box

This is where buildpacks genuinely shine over the other two. Every Paketo build emits a Software Bill of Materials:

Terminal window
pack sbom download myapp --output-dir /tmp/sbom
ls /tmp/sbom/
# launch/ build/ ...each containing CycloneDX and SPDX JSON

You get dependency provenance for every layer. For compliance-heavy environments (finance, healthcare, defense), this alone might justify the image size tradeoff.

Caching in CI

Buildpacks have a cache volume concept that works well locally but needs explicit setup in CI:

Terminal window
pack build myapp \
--builder paketobuildpacks/builder-jammy-full \
--cache-image ghcr.io/yourorg/myapp-cache \
--publish

Storing the cache image in your registry means CI workers that don’t share a local volume can still reuse the build cache. This is clever, though it adds registry bandwidth.

Multi-Arch

Terminal window
pack build myapp \
--builder paketobuildpacks/builder-jammy-full \
--platform linux/amd64 \
--platform linux/arm64

Support exists but the builder itself needs to support the target platform. Not all Paketo builders are multi-arch yet. Check before assuming.

When Buildpacks Make Sense

The sweet spot is a polyglot org or a platform team that wants a single build interface for all services. Instead of maintaining Dockerfiles across 20 different language runtimes, you standardize on pack build and let the buildpacks handle the language-specific details. Runtime security patches get rolled out by updating the run image, not by touching each service’s Dockerfile.

GCP Cloud Run, Heroku, and several Kubernetes platforms support CNB natively, so if you’re on one of those you might already be using this without knowing it.


Head-to-Head Comparison

koJibBuildpacks
LanguagesGo onlyJava, KotlinAny (Go, Java, Node, Python, Ruby, .NET…)
Image size~10–20MB (distroless)~200MB+ (JVM)~300–600MB (OS included)
Build speedExtremely fastFast (no daemon)Moderate to slow
Docker daemon neededNoNoNo (pack CLI uses containerd)
Reproducible buildsYesYes (with config)Partial (run image updates break digests)
Multi-archNative, trivialSupported, needs configSupported, builder-dependent
SBOMVia cosign/SLSANo built-inYes, CycloneDX + SPDX
Layer caching in CISource-levelDep-aware layersCache image in registry
Config overheadMinimalBuild pluginNone (zero-config detection)
When it breaksCGO, init systemsNon-JVM runtimesUnusual language versions, monorepos with custom toolchains

When You Still Need a Dockerfile

None of these tools are universally applicable. Reach for a Dockerfile when:


The Verdict

Ship Go services? Use ko. It’s faster than anything else, the images are tiny, multi-arch is a flag, and reproducibility comes free. The only reason not to use it is if your binary isn’t pure Go.

Ship Java/Kotlin/Scala? Use Jib. The layer-splitting alone makes it worth it in CI — you’ll stop paying to push 400MB on every build. Distroless base, JVM flags in config, no Docker daemon. This should be the default for any JVM shop.

Running a platform team or polyglot stack? Buildpacks. You want a single interface, you want SBOM output, and you’re willing to trade some image size for the ability to tell every service team “just run pack build.” The cache image strategy works, the SBOM story is genuinely good, and when a CVE drops in the runtime layer you patch the run image once and rebuild everything.

None of the above? Write the Dockerfile. It’s not a failure to admit that sometimes the straightforward tool is the right one. But at least now you know there’s a club that does it without one, and whether you belong in it.

Your 2 AM self will appreciate not debugging a COPY instruction in a Dockerfile at 2 AM.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Container Escape: How to Stop It
Next Post
Sentry Self-Hosted for Application Errors

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts