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.
# Install kogo install github.com/ko-build/ko@latest
# Build and push to your registryexport KO_DOCKER_REPO=ghcr.io/yourorgko build ./cmd/myappThat’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:
defaultBaseImage: gcr.io/distroless/base:nonrootReproducible 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
ko build --platform=linux/amd64,linux/arm64 ./cmd/myappGo 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
- 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/myappko 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-jreCOPY target/myapp.jar /app.jarENTRYPOINT ["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
<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># Build and push directly (no Docker daemon needed)./mvnw jib:build
# Build to local Docker daemon for local testing./mvnw jib:dockerBuildGradle Setup
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" }}./gradlew jibBase Image Choices
Jib defaults to eclipse-temurin which is reasonable but not small. For production:
<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:
<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.
# Install the pack CLIbrew install buildpacks/tap/pack # or grab the binary from github.com/buildpacks/pack/releases
# Build a Node apppack build myapp --builder paketobuildpacks/builder-jammy-full
# Build a Python apppack 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:
# Smaller, less language supportpack build myapp --builder paketobuildpacks/builder-jammy-tiny
# Check what buildpacks are in a builderpack inspect-builder paketobuildpacks/builder-jammy-fullSBOM Out of the Box
This is where buildpacks genuinely shine over the other two. Every Paketo build emits a Software Bill of Materials:
pack sbom download myapp --output-dir /tmp/sbomls /tmp/sbom/# launch/ build/ ...each containing CycloneDX and SPDX JSONYou 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:
pack build myapp \ --builder paketobuildpacks/builder-jammy-full \ --cache-image ghcr.io/yourorg/myapp-cache \ --publishStoring 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
pack build myapp \ --builder paketobuildpacks/builder-jammy-full \ --platform linux/amd64 \ --platform linux/arm64Support 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
| ko | Jib | Buildpacks | |
|---|---|---|---|
| Languages | Go only | Java, Kotlin | Any (Go, Java, Node, Python, Ruby, .NET…) |
| Image size | ~10–20MB (distroless) | ~200MB+ (JVM) | ~300–600MB (OS included) |
| Build speed | Extremely fast | Fast (no daemon) | Moderate to slow |
| Docker daemon needed | No | No | No (pack CLI uses containerd) |
| Reproducible builds | Yes | Yes (with config) | Partial (run image updates break digests) |
| Multi-arch | Native, trivial | Supported, needs config | Supported, builder-dependent |
| SBOM | Via cosign/SLSA | No built-in | Yes, CycloneDX + SPDX |
| Layer caching in CI | Source-level | Dep-aware layers | Cache image in registry |
| Config overhead | Minimal | Build plugin | None (zero-config detection) |
| When it breaks | CGO, init systems | Non-JVM runtimes | Unusual language versions, monorepos with custom toolchains |
When You Still Need a Dockerfile
None of these tools are universally applicable. Reach for a Dockerfile when:
- CGO dependencies: ko can’t handle C extensions. You need a builder stage with gcc/musl toolchains.
- Custom entrypoints: You’re running multiple processes, need an init system, or have a complex startup script. (Though honestly, if you need tini or s6, ask yourself why first.)
- Unsupported language versions: Buildpacks have a fixed matrix of supported runtimes. If you’re on a pre-release or patched runtime, you’ll be fighting the buildpack detection logic.
- Complex build pipelines: Webpack, Vite, asset compilation, code generation — if your build process has more than “compile → copy binary”, a Dockerfile with explicit stages often wins on clarity.
- Debugging / tooling images: Development containers with mounted source, devcontainers, images that need
vimandstrace— these are inherently custom and Dockerfiles are the right abstraction.
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.