Cloud Native Buildpacks
Building images without a Dockerfile: the CNB lifecycle, pack, builders and buildpacks, rebasing, kpack, and writing your own.
Cloud Native Buildpacks
Turning source into an OCI image with no Dockerfile, using a versioned spec, a set of composable buildpacks, and a lifecycle that keeps application layers separable from OS layers.
Overview
Cloud Native Buildpacks (CNB) is a specification plus a reference implementation for building container images from source. Heroku published the original Buildpack API in 2012 alongside the Cedar stack; Pivotal and Heroku jointly rewrote the idea as an open specification in January 2018 and donated it to the CNCF that October. It reached incubation in November 2020 and graduated in 2026 — the CNCF project record dates graduation 17 July 2026, and the public announcement went out on 11 August 2026.
So the honest answer to "is this still just a Heroku thing?" is no, and the persuasive evidence is not the CNCF badge but the list of places where CNB is the invisible default:
| Where | What actually runs | Notes |
|---|---|---|
Spring Boot bootBuildImage / spring-boot:build-image |
CNB lifecycle via the Paketo builder | Default builder in Spring Boot 3.5 and 4.x is paketobuildpacks/builder-noble-java-tiny:latest |
gcloud run deploy --source |
Google Cloud buildpacks | Docs state this path "always uses gcr.io/buildpacks/builder:latest" |
| Cloud Run functions, App Engine | Google Cloud buildpacks | Per-language builders under gcr.io/serverless-runtimes/ |
az containerapp up --source |
"CNCF Buildpacks", per Microsoft's own docs | Builder image is not named in current Azure docs; Go is documented as unsupported |
| GitLab Auto DevOps (Auto Build) | pack with a Heroku builder |
Auto Test still uses Herokuish — the spec has no test-suite detection concept |
| Heroku Fir | CNB exclusively | Classic buildpacks cannot be used on Fir |
| Fly.io, DigitalOcean App Platform, Dokku | pack |
Dokku's default is based on heroku/builder:24 |
| Tanzu Build Service 2.0 | kpack | Commercial; Tanzu Buildpacks are built on Paketo |
Most of those users have never typed pack. That is the real argument.
The equally honest qualifications, because a senior reader will find these anyway:
- Size. A buildpack image carries a full run base image, a launcher, and per-buildpack helper layers. The same Go binary measured below came out at 44.3 MB from a Paketo tiny build against 9.01 MB for a hand-rolled
FROM scratchimage. Against adistrolessor apko-built base you will pay a similar premium. - Opacity. When detection picks the wrong group or a buildpack misbehaves, you are debugging someone else's shell scripts inside a container you did not define. There is no
RUNline to comment out. - Coverage. Unusual runtimes, native extensions, or system packages that no buildpack provides mean either writing a buildpack, using an image extension, or going back to a Dockerfile.
- Cold-build cost. Builders are large — Paketo's Jammy base builder is ~1.3 GB compressed on Docker Hub, the full builder ~1.7 GB. First build on a fresh CI runner pays that.
- Control. You do not choose the base image layering, the user, the entrypoint, or the layer boundaries. You choose a builder and some environment variables.
Against all of that sits one capability Dockerfiles simply do not have: pack rebase, which swaps the OS layers underneath an already-built application image in seconds. That is the section to read if you read only one.
flowchart LR
SRC["Source code<br/>(no Dockerfile)"] --> LC
BLD["Builder image<br/>(build base + buildpacks + lifecycle)"] --> LC
LC["CNB lifecycle"] --> IMG["App image"]
RUN["Run base image"] --> IMG
IMG --> REG["Registry"]
RUN2["Patched run base image"] -.->|"pack rebase"| IMG
Build Image and Run Image
Everything else rests on this split, so get it straight first.
- The build image is the environment the buildpacks execute in. It has compilers, package managers, and shells. It is thrown away.
- The run image is the base of the final application image. The buildpacks' output layers are stacked on top of it. It is what ships.
- A builder is a build image with buildpacks and the lifecycle baked in, plus metadata naming its default run image.
Because the lifecycle records exactly which run image an app image was built on, and keeps application layers strictly above it, the run image can later be replaced without touching anything above — that is rebase.
Stacks are gone; targets replaced them
CNB used to call the build/run pairing a stack, identified by a string such as io.buildpacks.stacks.jammy. Stacks were deprecated in Platform API 0.12 / Buildpack API 0.10 in favour of ordinary container-image concepts: OS, architecture, and distribution. Buildpacks now receive target data as CNB_TARGET_OS (linux), CNB_TARGET_ARCH (amd64), CNB_TARGET_ARCH_VARIANT (v8), CNB_TARGET_DISTRO_NAME (ubuntu), and CNB_TARGET_DISTRO_VERSION (22.04).
Run and build images declare io.buildpacks.base.distro.name and io.buildpacks.base.distro.version labels; an optional io.buildpacks.base.id identifies a base "flavour" so that rebase can tell a tiny run image from a full one.
The migration is genuinely in flight rather than finished, and you will see both vocabularies in the same output. Real capture from pack builder inspect, September 2026:
Stack:
ID: io.buildpacks.stacks.jammy.tiny
pack stack is marked (deprecated) in pack --help, the lifecycle no longer writes a stack key into io.buildpacks.lifecycle.metadata (it writes runImage.image and runImage.mirrors instead), and Paketo publishes transitional -no-stacks run image variants that omit io.buildpacks.stack.id entirely. Write new material against build/run images and targets; treat a "stack" in older docs as "the build/run pair".
When a base image has no distro labels the lifecycle falls back, and says so:
target distro name/version labels not found, reading /etc/os-release file
The Lifecycle
The lifecycle is the binary set that does the actual work. pack is a convenience wrapper around it; so are kpack, Tekton's task, and Spring Boot's plugin.
Phase order is analyze → detect → restore → build → export. This trips people up: older material lists detect first, which was correct before Platform API 0.7. analyze was moved to the front so that registry access for every image involved is validated before anything expensive happens, and most of what it used to do moved to restore. The current order is what the lifecycle prints, verbatim, from a real build:
===> ANALYZING
[analyzer] Image with name "hello-bp-demo" not found
===> DETECTING
===> RESTORING
===> BUILDING
===> EXPORTING
These are literal API terms and stay in US spelling — analyze, analyzer, and exporter are what the spec, the binaries, and the image labels use, so "correcting" them breaks things.
flowchart TD
A["analyze<br/>read previous image + cache metadata"] --> D["detect<br/>choose an order group"]
D --> R["restore<br/>copy cached layers into the build container"]
R --> B["build<br/>run each buildpack's bin/build"]
B --> E["export<br/>assemble app layers on the run image"]
E --> OUT["App image + cache"]
D -.->|"no group passes"| FAIL["exit 20"]
| Phase | Binary | What it does |
|---|---|---|
analyze |
/cnb/lifecycle/analyzer |
"restores files that buildpacks may use to optimize the build and export phases" — reads metadata from the previous app image and the cache so later phases can skip work |
detect |
/cnb/lifecycle/detector |
"finds an ordered group of buildpacks to use during the build phase" |
restore |
/cnb/lifecycle/restorer |
"copies layers from the cache into the build container" |
build |
/cnb/lifecycle/builder |
"transforms application source code into runnable artifacts" |
export |
/cnb/lifecycle/exporter |
"creates the final OCI image and updates the image config with new metadata" |
Plus four more that are not part of the linear build:
| Binary | Purpose |
|---|---|
/cnb/lifecycle/creator |
Runs analyze, detect, restore, build, and export in one process and one container. Does not support image extension. |
/cnb/lifecycle/rebaser |
Swaps the run image under an existing app image. |
/cnb/lifecycle/launcher |
The app image entrypoint; resolves process types and layer environment. |
/cnb/lifecycle/extender |
Applies build.Dockerfile / run.Dockerfile emitted by image extensions. |
Why the separation matters
The phase split is not ceremony. Each phase writes a different, independently addressable artefact:
analyzeandrestoreare what make incremental builds possible — a buildpack that finds an unchanged dependency set reuses its layer from the previous image rather than rebuilding it.exportwrites application layers on top of the run image and recordsio.buildpacks.lifecycle.metadatadescribing exactly which run image, and which of its layers, sit underneath.- That metadata is precisely what
rebaseneeds. Because nothing in the application layers is entangled with the OS layers, they can be re-pointed.
A Dockerfile cannot do this: FROM ubuntu:22.04 followed by RUN apt-get install produces layers that are causally dependent on the base, so changing the base means re-running everything above it.
Image extensions
Extensions are the escape hatch for "I need an OS package". An extension runs bin/detect and then bin/generate, and outputs a build.Dockerfile, a run.Dockerfile, or both; the extender phase applies them. Extensions have their own detection order, prepended to each buildpack group, and can only provide in the build plan, never require. Two caveats: creator does not support extension (so trusted single-container builds skip it), and layers added to the run image by an extension may not be safe to rebase.
Buildpacks, Order Groups, and Builders
What a buildpack is
Three things in a directory: a buildpack.toml, an executable bin/detect, and an executable bin/build. That is the entire contract. Everything else — Paketo's Java buildpack, Google's Python buildpack — is an elaboration of it.
How detection actually picks
A builder ships an order: a list of groups, each group a list of buildpack entries, each entry optionally optional = true. The detector walks groups in order, runs every bin/detect in the group, and accepts the first group in which every non-optional buildpack passes (exit 0) and the build plan's requires can be satisfied by the provides. Optional buildpacks that fail are simply skipped.
Buildpacks can also be composite (also called meta or order buildpacks): instead of bin/detect/bin/build, their buildpack.toml contains its own [[order]], which is flattened into the parent order at detection time. paketo-buildpacks/go is a composite that expands to go-dist + go-mod-vendor + go-build.
The result is a tree, and pack builder inspect prints it. Real capture, Paketo Jammy base builder:
Detection Order:
├ Group #1:
│ ├ paketo-buildpacks/ruby@3.0.4
│ │ └ Group #1:
│ │ ├ paketo-buildpacks/ca-certificates@3.12.7 (optional)
│ │ ├ paketo-buildpacks/mri@2.0.4
│ │ ├ paketo-buildpacks/bundle-install@0.9.23
│ │ ├ paketo-buildpacks/puma@0.5.23
│ │ └ paketo-buildpacks/procfile@5.13.7 (optional)
...
Note that Ruby appears before Go and Python in that builder's order. This is the mechanism behind "my polyglot repo built as the wrong language" — detection is first-match on an ordered list, not a best-match. Pin with --buildpack or project.toml when a repo could plausibly satisfy two groups.
When nothing passes, the message is unhelpfully generic. Real capture:
======== Results ========
fail: paketo-buildpacks/go-build@2.4.39
skip: paketo-buildpacks/procfile@5.13.7
ERROR: No buildpack groups passed detection.
ERROR: Please check that you are running against the correct path.
ERROR: failed to detect: no buildpacks participating
ERROR: failed to build: executing lifecycle: failed with status code: 20
Exit status 20 always means detection. The fail:/skip: list above it is the useful part — skip means an optional buildpack declined, fail means a required one did.
What a builder bundles
flowchart TB
subgraph Builder["Builder image"]
BB["Build base image"]
BP["Buildpacks<br/>/cnb/buildpacks"]
LC["Lifecycle binaries<br/>/cnb/lifecycle"]
ORD["order.toml + metadata"]
RIM["Reference to the default run image"]
end
Builder --> Detect["detect / build run here"]
pack builder inspect reads all of it without pulling the image, which makes it the fastest way to answer "what will this thing do to my source". Real capture, trimmed:
Description: Ubuntu 24.04 Noble Numbat base image with buildpacks for Apache HTTPD,
Go, Java, Java Native Image, .NET, NGINX, Node.js, PHP, Procfile,
Python, and Ruby
Trusted: Yes
Stack:
ID: io.buildpacks.stacks.noble
Lifecycle:
Version: 0.21.18
Buildpack APIs: Supported: 0.7 ... 0.12
Platform APIs: Supported: 0.7 ... 0.15
Run Images:
docker.io/paketobuildpacks/ubuntu-noble-run:latest
The two versioned APIs
Both are gates, not decoration:
- Buildpack API — the contract between the lifecycle and a buildpack's
bin/detect/bin/build. Current version 0.12. A buildpack declaresapi = "0.12"at the top ofbuildpack.toml; a lifecycle that does not support that version refuses to run it. - Platform API — the contract between a platform (
pack, kpack, Tekton) and the lifecycle. Current version 0.15.
pack report shows what you have — on 0.40.9 that is a bundled lifecycle 0.21.0 and Platform APIs 0.3 through 0.15. Set CNB_PLATFORM_API to force a level when driving the lifecycle directly.
pack in Anger
Install
Download the release binary and checksum-verify it; there is no reason to curl-pipe a script.
# Latest release at time of writing: v0.40.9 (2026-08-09)
curl -sSLO https://github.com/buildpacks/pack/releases/download/v0.40.9/pack-v0.40.9-linux.tgz
curl -sSLO https://github.com/buildpacks/pack/releases/download/v0.40.9/pack-v0.40.9-linux.tgz.sha256
sha256sum -c pack-v0.40.9-linux.tgz.sha256
# pack-v0.40.9-linux.tgz: OK
tar -xzf pack-v0.40.9-linux.tgz -C ~/.local/bin
pack --version
# 0.40.9+git-8210eb1.build-6996
pack needs a Docker-API daemon. With rootless Podman, start the user socket and tell pack to pass it through — see the Podman note under Common Issues, because the default fails in a way that reads like a permissions bug.
Building
# Simplest possible: detect everything from the current directory
pack build myapp --builder paketobuildpacks/ubuntu-noble-builder
# Explicit source path, extra tag, chosen default process, build-time env.
# --env with no value reads from the current shell.
pack build myapp \
--path ./services/api \
--builder paketobuildpacks/ubuntu-noble-builder \
--tag registry.example.com/team/myapp:1.4.2 \
--default-process web \
--env BP_JVM_VERSION=21 \
--env-file ./build.env \
--env GITHUB_TOKEN
# Override the builder's order entirely. --buildpack takes a buildpack id already on
# the builder, a local directory, a .cnb file, or a packaged buildpack image like this one.
pack build myapp \
--builder paketobuildpacks/builder-jammy-buildpackless-static \
--buildpack paketobuildpacks/go
# Push straight to a registry; the daemon is never involved. And cross-build.
pack build registry.example.com/team/myapp:1.4.2 --publish
pack build myapp --platform linux/arm64
--env sets build-time only variables. pack build --help says so in capitals: NOTE: These are NOT available at image runtime. Runtime environment comes from the buildpack's env.launch layer contributions, an exec.d helper, or your orchestrator.
Caching
Default is two Docker volumes, one for build layers and one for launch layers — pack build --help reports it as (default type=build;format=volume;type=launch;format=volume;). Three formats, and the syntax is a semicolon-delimited key/value string, not a path:
pack build myapp --cache 'type=build;format=volume;name=myapp-build-cache'
pack build myapp --cache 'type=build;format=bind;source=/var/cache/cnb/myapp'
pack build myapp --clear-cache # nuke it
# Registry-backed cache — the only one that survives an ephemeral runner.
# Requires --publish.
pack build registry.example.com/team/myapp:1.4.2 \
--publish \
--cache-image registry.example.com/team/myapp-cache:main
--cache-image is the one that matters for GitHub-hosted runners, GitLab shared runners, or anything else with no persistent disk. It needs push access to that repository, which is a separate grant from pushing the app image.
Other flags worth knowing
| Flag | Effect |
|---|---|
--volume 'host:target[:opts]' |
Mount a host path into the build container. Default option is ro; add :rw explicitly if a buildpack must write. Useful for private CA bundles and vendored dependency mirrors. |
--run-image |
Override the builder's default run image. Both must be compatible targets. |
--previous-image |
Point analysis at a different image than the output tag; needed when the output tag is not the last-built image. |
--creation-time now |
Stamp a real timestamp instead of the reproducible default. Requires Platform API ≥ 0.9. |
--pull-policy |
always (default), never, if-not-present. always is a genuine surprise on a slow link. |
--sbom-output-dir, --report-output-dir |
Write the SBOM, and report.toml carrying the resulting image digest — the thing your pipeline wants to record. |
--lifecycle-image |
Which lifecycle image untrusted builds use for analyze, restore, and export. |
--pre-buildpack / --post-buildpack |
Prepend or append to the builder's groups instead of replacing them, which is what plain --buildpack does. |
--insecure-registry |
Requires Platform API ≥ 0.13. |
Note the default creation time. Real capture from a Paketo-built image:
podman image inspect hello-go --format 'Created={{.Created}}'
# Created=1980-01-01 00:00:01 +0000 UTC
That fixed epoch is deliberate — it makes builds reproducible. Three separate builds of the same source in the test below produced the same image ID (8d1a837b6cea). If your registry UI or admission policy cares about creation dates, use --creation-time now and accept the loss of reproducibility.
Trusted builders — what trust actually changes
This is a security boundary, not a convenience setting.
- Trusted builder:
packruns/cnb/lifecycle/creator, all five phases in one container. - Untrusted builder:
packruns five separate containers, one per phase, and uses a separate lifecycle image (not the builder's) for the privileged ones.
The reason is that analyze, restore, and export need registry credentials and elevated access, while detect and build execute third-party code. Putting them in one container hands buildpack authors your registry credentials. Splitting them does not.
flowchart TB
subgraph Trusted["Trusted: 1 container"]
C["creator: analyze + detect + restore + build + export"]
end
subgraph Untrusted["Untrusted: 5 containers"]
A2["analyzer<br/>lifecycle image, has credentials"]
D2["detector<br/>builder image, third-party code"]
R2["restorer<br/>lifecycle image, has credentials"]
B2["builder<br/>builder image, third-party code"]
E2["exporter<br/>lifecycle image, has credentials"]
A2 --> D2 --> R2 --> B2 --> E2
end
pack trusts a fixed list out of the box — everything pack builder suggest names. Real capture from pack config trusted-builders list on 0.40.9, abridged:
Trusted Builders:
gcr.io/buildpacks/builder:google-22
heroku/builder:20 :22 :24 :26
paketobuildpacks/builder-jammy-base -full -tiny -buildpackless-static
paketobuildpacks/builder-ubi8-base, ubi-9-builder, ubi-10-builder
paketobuildpacks/ubuntu-noble-builder
Two footguns hide in that list. gcr.io/buildpacks/builder:latest is not on it — only the google-22 tag is — so the currently recommended Google builder builds untrusted, and slower. Neither is paketobuildpacks/builder-noble-java-tiny, which is Spring Boot's default builder.
pack config trusted-builders add registry.example.com/platform/builder:2026-09
pack build myapp --builder ... --trust-builder # one-off
Adding extra buildpacks silently drops you into the five-container flow even on a trusted builder — Warning: Builder is trusted but additional modules were added; using the untrusted (5 phases) build flow. --trust-extra-buildpacks overrides that. Only do it for buildpacks you built.
Inspecting the result
pack inspect-image myapp # also available as: pack inspect myapp
Stack: io.buildpacks.stacks.jammy.tiny
Base Image:
Reference: a0b38df9d82d49aeba08d70e88d50d1b8adf227c4e5f0b4c7d0cd0f410be14bf
Top Layer: sha256:c89fde280881a9fb1d475223bffa0a0558f3ab7743aaf8077b44ba34ee167e03
Run Images:
index.docker.io/paketobuildpacks/run-jammy-tiny:latest
Rebasable: true
Buildpacks:
ID VERSION HOMEPAGE
paketo-buildpacks/go-dist 2.10.28 ...
paketo-buildpacks/go-build 2.4.39 ...
Processes:
TYPE SHELL COMMAND WORK DIR
hello (default) /layers/paketo-buildpacks_go-build/targets/bin/hello /workspace
Rebasable: true is the field to assert on in CI. Base Image: Reference is what changes after a rebase. The image config is worth a look too — entrypoint is always a launcher shim, and the user is never root:
podman image inspect myapp \
--format 'Entrypoint={{.Config.Entrypoint}} User={{.Config.User}} WorkingDir={{.Config.WorkingDir}}'
# Entrypoint=[/cnb/process/hello] User=1002:1000 WorkingDir=/workspace
Other pack verbs
pack builder suggest # curated list from KnownBuilders
pack builder inspect <builder> # order tree, lifecycle, APIs, run images
pack builder create my-builder --config builder.toml
pack buildpack package my-bp --config package.toml # → OCI image
pack buildpack package my-bp.cnb --config package.toml --format file
pack sbom download myapp --output-dir ./sbom
pack config default-builder paketobuildpacks/ubuntu-noble-builder
pack config subcommands: default-builder, experimental, pull-policy, registries, run-image-mirrors, trusted-builders, lifecycle-image, registry-mirrors.
pack rebase — the argument that Dockerfiles cannot answer
A CVE lands in the base OS. With a Dockerfile you rebuild: re-run the compiler, re-resolve dependencies, re-run the tests, re-approve the artefact. With CNB you replace the run image layers underneath the unchanged application layers, and the application bits are byte-identical to what you already tested.
Measured
Verified on Debian 13, 1 vCPU / 2 GB, rootless Podman, pack 0.40.9, lifecycle 0.21.18, builder paketobuildpacks/builder-jammy-tiny, a trivial Go HTTP service. The run image was pinned to run-jammy-tiny:0.2.148 (2026-07-09) for the initial build, then :latest was moved to 0.2.171 (2026-09-04) to simulate a published patch:
| Operation | Wall clock |
|---|---|
pack rebase |
3 s |
| Full rebuild, warm cache | 55–56 s |
Full rebuild, --clear-cache |
53 s |
Roughly 18× faster on a toy Go app whose compile takes 35 seconds. On a Spring Boot service with a multi-minute Maven resolve the ratio is far larger, and the rebase time barely moves, because rebase does not scale with application size — it rewrites layer metadata and pushes a manifest.
The base image reference changed and the top layer changed; nothing above did:
# before
Base Image:
Reference: 0f59f6482517467bcbcc0c6bf6bc6f22a3545c4ccc0cce12213e2dc9836c2d5f
Top Layer: sha256:e284ebb4ca202d2018d581a033283e54a1102c94fe6a4da16b59902be34d685f
# pack rebase hello-go2
Rebasing 'hello-go2' on run image 'index.docker.io/paketobuildpacks/run-jammy-tiny:latest'
Rebased Image: 'a4c0b8720d8d7714075cc7a1bad66c0a63317a5da817ce59ad5c4d2eb90d46f0'
Successfully rebased image 'hello-go2'
# after
Base Image:
Reference: a0b38df9d82d49aeba08d70e88d50d1b8adf227c4e5f0b4c7d0cd0f410be14bf
Top Layer: sha256:c89fde280881a9fb1d475223bffa0a0558f3ab7743aaf8077b44ba34ee167e03
The rebased container still served requests unchanged.
flowchart LR
subgraph Before["App image, before"]
A1["App layers"] --> R1["Run image v1<br/>(vulnerable)"]
end
subgraph After["App image, after rebase"]
A2["App layers<br/>(byte-identical)"] --> R2["Run image v2<br/>(patched)"]
end
Before -->|"3 s, no rebuild, no retest"| After
The CVE-patching workflow
# Rebase every deployed tag. Idempotent: a no-op if the base is already current.
# --report-output-dir writes report.toml carrying the resulting digest, for the audit trail.
for tag in 1.4.2 1.4.1 1.3.9; do
pack rebase registry.example.com/team/myapp:$tag --publish --report-output-dir ./out
done
pack inspect-image registry.example.com/team/myapp:1.4.2
--publish operates registry-to-registry: the previous image must already be in the registry, and no local daemon copy is made. That is what makes it viable to rebase a hundred tags from a CI job.
What makes an image rebasable, and what refuses
The lifecycle validates before it will swap:
- The app image carries
io.buildpacks.rebasable: true. A run image author sets this false when the image contains something that application layers may have compiled against. - The new run image's target data matches: os, architecture, architecture variant,
io.buildpacks.base.distro.name, andio.buildpacks.base.distro.version. - The new run image is named in the app image's recorded
runImage.imageor one of itsrunImage.mirrors.
Fail any of those and you get a refusal, quoting the metadata. Real capture:
ERROR: rebase app image: new base image 'paketobuildpacks/run-jammy-base' not found in
existing run image metadata: {"topLayer":"sha256:1a258f98...","reference":"9fec6599...",
"image":"index.docker.io/paketobuildpacks/run-jammy-tiny:latest"};
please provide -force to override
--force (the lifecycle spells it -force in that message; pack's flag is --force) skips validation. It succeeds, loudly:
Warning: rebase app image: new base image 'paketobuildpacks/run-jammy-base' not found in
existing run image metadata: ...
Successfully rebased image 'hello-go2'
Do not do that. Rebasing a tiny app onto a base run image, or across a glibc version, produces an image that passes every check you have and segfaults at runtime. --force exists for run images that are genuinely equivalent but renamed — a registry migration, say — and for nothing else.
The genuine constraints: rebase patches the OS only, so a CVE in your application's dependencies still needs a rebuild; it does not re-run buildpacks, so a buildpack fix does not reach a rebased image; layers added to a run image by an image extension may not be safe to rebase; and an ABI change — a new Ubuntu LTS, a musl/glibc switch — is a rebuild, which is exactly what the validation exists to stop you doing by accident.
Mirrors matter more than they look. Configure them so rebase can resolve a run image from the same registry as the app image, avoiding a cross-registry copy:
pack config run-image-mirrors add paketobuildpacks/ubuntu-noble-run \
--mirror registry.example.com/mirror/ubuntu-noble-run
project.toml
The project descriptor keeps build configuration in the repository rather than in a CI script. pack reads ./project.toml automatically; --descriptor points elsewhere.
[_]
schema-version = "0.2"
id = "com.example.api"
name = "Example API"
version = "1.4.2"
[[_.licenses]]
type = "Apache-2.0"
[_.metadata]
team = "platform"
[io.buildpacks]
builder = "paketobuildpacks/ubuntu-noble-builder"
# Only these paths are sent to the build. Mutually exclusive with exclude.
include = ["cmd/", "internal/", "go.mod", "go.sum"]
[[io.buildpacks.build.env]]
name = "BP_GO_TARGETS"
value = "./cmd/api"
# Pin the group instead of relying on detection order
[[io.buildpacks.group]]
id = "paketo-buildpacks/go-dist"
[[io.buildpacks.group]]
id = "paketo-buildpacks/go-build"
Notes that catch people out:
- The root table is
[_], not[project]. The rename came with schema version 0.2. includeandexcludeare mutually exclusive, andincludeis a whitelist — anything unlisted never reaches the build container. This is the correct way to keep a 400 MBnode_modulesor a.gitdirectory out of a build.- A
[[io.buildpacks.group]]entry may carry an inlinescript(withapi,inline, and optionalshell) instead of referencing a buildpack, which is handy for a one-off build step without packaging a buildpack. - Heroku accepts
project.tomlbut restricts it: schema-version 0.2 or omitted, Platform API 0.12, the builder must come from theheroku/builderrepository, andinclude/excludeare unsupported.
Builders in Practice
Three vendors ship builders that pack suggests and trusts by default: Google, Heroku, and Paketo. Everyone else is downstream of one of them.
Paketo (Cloud Foundry lineage)
Paketo is the broadest set and the default in Spring Boot, Fly.io, and most pack documentation. The naming convention changed between generations and there is no single pattern to extrapolate from — check the exact string.
| Builder | Base | Default run image |
|---|---|---|
paketobuildpacks/ubuntu-resolute-builder |
Ubuntu 26.04 | paketobuildpacks/ubuntu-resolute-run |
paketobuildpacks/ubuntu-noble-builder |
Ubuntu 24.04 | paketobuildpacks/ubuntu-noble-run |
paketobuildpacks/builder-noble-java-tiny |
Ubuntu 24.04 build, scratch-like run | paketobuildpacks/ubuntu-noble-run-tiny |
paketobuildpacks/builder-jammy-base |
Ubuntu 22.04 | paketobuildpacks/run-jammy-base |
paketobuildpacks/builder-jammy-full |
Ubuntu 22.04 | paketobuildpacks/run-jammy-full |
paketobuildpacks/builder-jammy-tiny |
Ubuntu 22.04 build, scratch-like run | paketobuildpacks/run-jammy-tiny |
paketobuildpacks/ubi-9-builder |
RHEL UBI 9 | paketobuildpacks/run-ubi9-base |
paketobuildpacks/ubi-10-builder |
RHEL UBI 10 | paketobuildpacks/ubi-10-run-base |
paketobuildpacks/builder-ubi8-base |
RHEL UBI 8 | paketobuildpacks/run-ubi8-base |
Word order flips between generations (build-jammy-base versus ubuntu-noble-build), and the UBI line uses two conventions at once. Most builders also have a -buildpackless variant carrying the base and lifecycle but no buildpacks, intended for --buildpack-driven assembly. The Resolute (26.04) line is documented by Paketo but is newer than pack 0.40.9's built-in KnownBuilders, so it does not appear in pack builder suggest and is not trusted by default.
tiny / base / full is a Jammy-era taxonomy and it is expiring. For Jammy: tiny covers Go and GraalVM native images on a distroless-like run image; base covers the managed runtimes without common C libraries; full adds PHP and those libraries. Noble has no full — Paketo deprecated it, and the replacement is the Apt buildpack for the packages you actually need. Approximate compressed sizes on Docker Hub as of September 2026: builder-jammy-tiny 430 MB, builder-jammy-base 1314 MB, builder-jammy-full 1708 MB, ubuntu-noble-builder 1428 MB; run images run-jammy-tiny 10.6 MB, run-jammy-base 55.5 MB, run-jammy-full 279 MB.
The trap worth writing on the wall: paketobuildpacks/builder:base (and :full, :tiny) are archived. The Docker Hub description opens "⚠️ These images are archived. No further releases will be made!"; they are Ubuntu 18.04, last pushed November 2023, and Bionic support ended in May 2023 with no further CVE fixes. Any tutorial, blog post, or CI config still using the unsuffixed paketobuildpacks/builder repository is building on a three-year-old unpatched base. There is no deprecation notice on the builder-jammy-* line yet, but Paketo's own migration guidance is that builder-jammy-base users should move to ubuntu-noble-builder as a direct swap.
Google Cloud
| Tag | Base |
|---|---|
gcr.io/buildpacks/builder:latest |
Ubuntu 24 (currently the same as google-24) |
gcr.io/buildpacks/builder:google-24 |
Ubuntu 24 |
gcr.io/buildpacks/builder:google-22 |
Ubuntu 22 |
gcr.io/buildpacks/builder:v1 |
Ubuntu 18 — legacy, avoid |
Covers .NET, Dart, Go, Java, Node.js, PHP, Python, and Ruby. Run image for the Ubuntu 24 line is gcr.io/buildpacks/google-24/run. App Engine and Cloud Run functions use separate per-language builders under gcr.io/serverless-runtimes/ for start-up speed — Google is the one vendor genuinely shipping language-family builders. Remember that only :google-22 is in pack's trusted list.
Heroku
| Builder | Base | Arch | Run image | Status |
|---|---|---|---|---|
heroku/builder:26 |
Ubuntu 26.04 | amd64 + arm64 | heroku/heroku:26 |
Recommended upstream |
heroku/builder:24 |
Ubuntu 24.04 | amd64 + arm64 | heroku/heroku:24 |
Heroku platform default |
heroku/builder:22 |
Ubuntu 22.04 | amd64 | heroku/heroku:22-cnb |
Deprecated |
.NET, Go, Java, Node.js, PHP, Python, Ruby, and Scala. Heroku publishes run image mirrors, which shows up in pack builder inspect as both docker.io/heroku/heroku:26 and public.ecr.aws/heroku/heroku:26. Note the split: pack builder suggest promotes :26, but Heroku's own platform still defaults to :24.
Picking one
- Java, Spring, or polyglot with the widest buildpack coverage → Paketo (
ubuntu-noble-builder, orbuilder-noble-java-tinyif you only build Java and want the smallest result). - Deploying to Google Cloud → Google's, because it is what the platform runs anyway, so local and remote builds agree.
- Deploying to Heroku, GitLab Auto DevOps, or Dokku → Heroku's, for the same reason.
- RHEL/UBI compliance requirement → Paketo's
ubi-9-builderorubi-10-builder, noting the narrow language coverage (Node.js, Java, Quarkus on 9; Node.js only on 10). - Smallest image → a
tiny/staticrun image with a compiled language, and accept that there is no shell forkubectl execdebugging. That trade is the whole point of the variant.
SBOM and Provenance
CNB emits SBOMs natively, with no extra tooling and no separate scan step. Buildpacks write <layer>.sbom.<ext> and the exporter collects them into the image.
Supported formats: CycloneDX (sbom.cdx.json), SPDX (sbom.spdx.json), and Syft (sbom.syft.json). A buildpack declares which it produces via sbom-formats in buildpack.toml.
pack sbom download myapp --output-dir ./sbom
pack sbom download registry.example.com/team/myapp:1.4.2 --remote --output-dir ./sbom
Real captured layout, Paketo Go build:
sbom/layers/sbom/launch/paketo-buildpacks_go-build/targets/sbom.cdx.json
sbom/layers/sbom/launch/paketo-buildpacks_go-build/targets/sbom.spdx.json
sbom/layers/sbom/launch/paketo-buildpacks_go-build/targets/sbom.syft.json
sbom/layers/sbom/launch/buildpacksio_lifecycle/launcher/sbom.cdx.json
sbom/layers/sbom/launch/paketo-buildpacks_ca-certificates/helper/sbom.syft.json
sbom/layers/sbom/launch/sbom.legacy.json
The path is layers/sbom/<launch|build|cache>/<buildpack id with / replaced by _>/<layer>/sbom.<ext>. Note that it is per layer, not per image — you get one document per contributing layer and must merge them if your downstream expects a single BOM. Paketo's are Syft-generated, and say so in metadata.tools ({"name": "syft", "vendor": "anchore", "version": "v1.51.1"}).
Two consequences worth planning for. First, the SBOM covers what the buildpacks installed — the Go toolchain, the JDK, your module graph — but not necessarily every OS package in the run image, so it complements rather than replaces an image scan. Second, because a rebase does not re-run buildpacks, the SBOM in a rebased image still describes the application layers correctly but says nothing about the new OS layers. Scan rebased images; do not assume the SBOM moved with them.
To generate and scan in one pipeline step, cross-reference Grype and Syft for syft/grype, Trivy for a single-binary scan of the built image, and Clair for a registry-side scanning service. Image signing and attestation belong to Container Registries.
Build provenance beyond the SBOM lives in three labels the exporter always writes: io.buildpacks.lifecycle.metadata (run image reference, top layer, per-buildpack layer digests), io.buildpacks.build.metadata (buildpack IDs and versions, processes, the build plan), and io.buildpacks.project.metadata (whatever project.toml declared).
podman image inspect myapp --format '{{index .Labels "io.buildpacks.build.metadata"}}' | jq '.buildpacks'
Kubernetes-Native Builds: kpack
kpack runs the lifecycle as Kubernetes pods and reconciles application images the way a Deployment reconciles pods. It lives at buildpacks-community/kpack (moved from pivotal/kpack; old URLs still redirect), is actively maintained at a roughly quarterly cadence, and is the engine underneath Tanzu Build Service 2.0. Latest release at time of writing: v0.18.0, July 2026.
kubectl apply -f https://github.com/buildpacks-community/kpack/releases/download/v0.18.0/release-0.18.0.yaml
Note the asset naming: v on the tag segment, no v on the filename.
Resources
Ten kinds under kpack.io/v1alpha2 (v1alpha1 is still served but is not the storage version and is slated for removal):
| Kind | Scope | Purpose |
|---|---|---|
Image |
namespaced | The desired app image; the thing you actually write |
Build |
namespaced | One build execution, created by the Image controller |
Builder / ClusterBuilder |
ns / cluster | A builder assembled by kpack from a stack, a store, and an order |
ClusterStack |
cluster | Build and run base image pair |
ClusterStore |
cluster | A collection of buildpackage images |
Buildpack / ClusterBuildpack |
ns / cluster | A single buildpackage image |
ClusterLifecycle |
cluster | The lifecycle binaries image |
SourceResolver |
namespaced | Resolves git/blob/registry source to a concrete revision |
kpack has not followed the spec in dropping stacks. ClusterStack still carries id, buildImage.image, and runImage.image, and neither it nor ClusterStore is deprecated. ClusterBuildpack did not replace ClusterStore — they coexist, with a Builder resolving buildpack IDs in order: namespaced Buildpacks, then ClusterBuildpacks, then the ClusterStore. ClusterLifecycle is orthogonal: it externalises the lifecycle image that used to be baked into the install, and the release YAML ships a default-lifecycle instance.
apiVersion: kpack.io/v1alpha2
kind: ClusterStack
metadata: {name: base}
spec:
id: "io.buildpacks.stacks.jammy"
buildImage: {image: "paketobuildpacks/build-jammy-base"}
runImage: {image: "paketobuildpacks/run-jammy-base"}
---
apiVersion: kpack.io/v1alpha2
kind: ClusterStore
metadata: {name: default}
spec:
sources:
- image: paketobuildpacks/java
- image: paketobuildpacks/nodejs
---
apiVersion: kpack.io/v1alpha2
kind: Builder
metadata: {name: my-builder, namespace: default}
spec:
serviceAccountName: kpack-sa
tag: registry.example.com/platform/my-builder
stack: {name: base, kind: ClusterStack}
store: {name: default, kind: ClusterStore}
order:
- group: [{id: paketo-buildpacks/java}]
- group: [{id: paketo-buildpacks/nodejs}]
---
apiVersion: kpack.io/v1alpha2
kind: Image
metadata: {name: api, namespace: default}
spec:
tag: registry.example.com/team/api
serviceAccountName: kpack-sa
builder: {name: my-builder, kind: Builder}
source:
git:
url: https://github.example.com/team/api
revision: main
Cluster-scoped resources take serviceAccountRef: {name, namespace} rather than serviceAccountName, and ClusterBuilder drops metadata.namespace.
Automatic rebuilds — the whole point
kpack watches for change and rebuilds without being asked. The reason is recorded on each Build as the image.kpack.io/reason annotation, with a diff in image.kpack.io/buildChanges:
| Reason | Trigger |
|---|---|
COMMIT |
New commit on the tracked git revision |
CONFIG |
The Image spec changed |
BUILDPACK |
A buildpack in the builder was updated |
STACK |
The build or run base image was updated |
LIFECYCLE |
The lifecycle image was updated |
TRIGGER |
Manual |
And it rebases rather than rebuilds when it safely can. From kpack's own source: if the build reason is exactly and only STACK, and the previous build's stack ID matches the builder's, kpack schedules a single-container rebase pod instead of the full phase sequence. Any other reason in the set — STACK plus BUILDPACK, say — is a full rebuild. That is the behaviour that makes a fleet-wide CVE patch a metadata operation across every application in the cluster.
Two operational facts that are easy to assume wrongly:
- The polling intervals are not configurable.
SourcePollingFrequencyandBuilderPollingFrequencyare hardcoded at one minute in the controller; there is no flag, field, or environment variable. Do not promise a tunable interval. ClusterStack,ClusterStore,Buildpack, andClusterBuildpackdo not poll for upstream updates. kpack re-resolves digests for images those resources already name, but something in your pipeline has to bump the tag or digest when a new base image is published. That is usually a scheduled job, and it is the piece people forget.
Running the Lifecycle in CI
With pack
The CNB project ships composite GitHub Actions. They are versioned with full semver only — @v6, @v6.1, and @latest are all 404s, verified against the tags API, so pin the complete version.
name: build
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: buildpacks/github-actions/setup-pack@v6.1.1
with:
pack-version: 0.40.9 # default pinned version in v6.1.1 is also 0.40.9
- name: Log in to GHCR
# or plain `docker login`, which pack reads from the Docker config
run: echo "${{ secrets.GITHUB_TOKEN }}" \
| pack config registries add ghcr --username "${{ github.actor }}" --password-stdin
- name: Build and publish
run: |
pack build ghcr.io/${{ github.repository }}:${{ github.sha }} \
--builder paketobuildpacks/ubuntu-noble-builder \
--cache-image ghcr.io/${{ github.repository }}-cache:main \
--report-output-dir ./out \
--publish
The other actions in that repository are setup-tools (installs crane and yj), plus a set of Docker actions referenced as docker://ghcr.io/buildpacks/actions/<path> — not the buildpacks/github-actions/<path>@tag form. There is no build-image action, whatever older blog posts say. The buildpacks.io page on publishing with GitHub Actions is stale and still shows @v5.1.0. See GitHub Actions for workflow structure and OIDC-based registry auth.
Without pack
Any platform can drive the lifecycle directly. The binaries live at /cnb/lifecycle/ in the builder, and are separately published as buildpacksio/lifecycle:<version> — note the image tag has no v prefix even though the git tag does (buildpacksio/lifecycle:0.21.18, not v0.21.18).
A platform must either run the phases in order or run creator. Creator's interface, from the spec — single-dash Go-style flags, and the output image is a positional argument:
export CNB_PLATFORM_API=0.15
/cnb/lifecycle/creator \
-app /workspace \
-layers /layers \
-cache-image registry.example.com/team/myapp-cache:main \
-run-image paketobuildpacks/ubuntu-noble-run \
-report /out/report.toml \
-process-type web \
-log-level info \
registry.example.com/team/myapp:1.4.2
Creator-specific inputs are -previous-image (CNB_PREVIOUS_IMAGE, defaults to the output image), -skip-restore (CNB_SKIP_RESTORE, default false), and repeatable -tag. Some flags are gated on Platform API level: -system needs ≥ 0.15, -insecure-registry ≥ 0.13, -layout/-layout-dir/-run ≥ 0.12. -image is deprecated in favour of -run-image.
Creator does not perform image extension. If you use extensions, run the phases separately with extender between restore and build, which is what Tekton's buildpacks-phases task does.
Tekton, CircleCI (via the buildpacks/pack orb), GitLab Auto DevOps, and SAP's Project "Piper" are the CI integrations the project documents. The official worked example on buildpacks.io walks the five separate phases using locally built binaries rather than the published image, so a docker run buildpacksio/lifecycle recipe is something you assemble from the spec, not something you copy from the docs.
Writing a Custom Buildpack
Three files. This one is real — it was built and run against Paketo's Jammy base builder for this sheet.
my-bp/
├── buildpack.toml
└── bin/
├── detect
└── build
buildpack.toml
api = "0.12"
[buildpack]
id = "example/hello-bp"
version = "0.0.1"
name = "Example Hello Buildpack"
homepage = "https://example.com/hello-bp"
sbom-formats = ["application/vnd.cyclonedx+json"]
[[targets]]
os = "linux"
api is the Buildpack API this buildpack is written against, and the lifecycle refuses anything it does not support. Current is 0.12; builders shipping lifecycle 0.21.x support 0.7 through 0.12.
[[targets]] replaced the old [[stacks]] list, and it is a filter: the more you specify, the fewer builders your buildpack will detect against. os = "linux" alone matches everywhere. Adding arch, or a [[targets.distros]] table with name and version, restricts it — a buildpack pinned to ubuntu 24.04 silently fails detection on a Jammy builder, which looks exactly like a broken bin/detect. Narrow it only when the buildpack genuinely ships a compiled artefact for one target.
For a composite buildpack, drop bin/ entirely and give the same file an [[order]] with [[order.group]] entries (id, version, optional) instead.
bin/detect
Exit 0 to pass, 100 to fail (this buildpack does not apply), anything else is an error. Working directory is the application source.
#!/usr/bin/env bash
set -euo pipefail
[[ -f hello.txt ]] || exit 100
cat >> "${CNB_BUILD_PLAN_PATH}" <<'PLAN'
[[provides]]
name = "hello"
[[requires]]
name = "hello"
PLAN
Inputs: CNB_BUILD_PLAN_PATH, CNB_PLATFORM_DIR, CNB_BUILDPACK_DIR (read-only), CNB_EXEC_ENV, plus the CNB_TARGET_* variables.
bin/build
#!/usr/bin/env bash
set -euo pipefail
layer="${CNB_LAYERS_DIR}/greeting"
mkdir -p "${layer}/bin"
cat > "${layer}/bin/greet" <<'SCRIPT'
#!/bin/sh
echo "greeting: $(cat /workspace/hello.txt)"
SCRIPT
chmod +x "${layer}/bin/greet"
# The layer contract: three booleans decide the layer's fate.
cat > "${CNB_LAYERS_DIR}/greeting.toml" <<'TOML'
[types]
launch = true
build = false
cache = false
TOML
# Process types for the launcher. command is an ARRAY from Buildpack API 0.9.
cat > "${CNB_LAYERS_DIR}/launch.toml" <<TOML
[[processes]]
type = "greet"
command = ["${layer}/bin/greet"]
default = true
TOML
Inputs: CNB_LAYERS_DIR, CNB_PLATFORM_DIR, CNB_BP_PLAN_PATH, CNB_BUILDPACK_DIR, CNB_EXEC_ENV.
The layer contract
The three booleans in <layer>.toml are the whole caching model:
| Flag | Meaning |
|---|---|
launch = true |
The layer ships in the app image |
build = true |
The layer is available to later buildpacks in the group during build |
cache = true |
The layer is stored in the build cache and restored on the next build |
launch=false, build=true, cache=true is a compiler toolchain. launch=true, build=false, cache=false is a compiled artefact. launch=true, cache=true is a dependency set you want to both ship and reuse — and it is what makes the second build fast.
Subdirectories inside a layer carry environment and hooks: <layer>/env/ applies to all phases, env.build/ and env.launch/ to one phase each, env.launch/<process>/ to a single process type, and exec.d/ holds executables run at launch to emit environment dynamically (this is how the Java buildpack sizes the heap from the cgroup limit).
Other outputs: launch.toml may also declare [[slices]] (splitting the app layer along path globs, for better registry dedup) and [[labels]]; build.toml declares [[unmet]] plan entries; SBOMs go to <layers>/<layer>.sbom.cdx.json, <layers>/build.sbom.<ext>, or <layers>/launch.sbom.<ext>.
Build it and run it
$ pack build hello-bp-demo --path ./app --buildpack ./my-bp \
--builder paketobuildpacks/builder-jammy-base
Warning: Builder is trusted but additional modules were added; using the untrusted (5 phases) build flow
===> ANALYZING
===> DETECTING
...
Successfully built image 'hello-bp-demo'
$ podman run --rm hello-bp-demo
greeting: world
Packaging and distributing
# package.toml
[buildpack]
uri = "./my-bp"
# Composite buildpacks list their children here
[[dependencies]]
uri = "docker://registry.example.com/bp/deps:1.0.0"
[platform]
os = "linux"
pack buildpack package registry.example.com/bp/hello:0.0.1 --config package.toml --publish
pack buildpack package hello-bp.cnb --config package.toml --format file
A packaged buildpack ("buildpackage") is an OCI image, so it lives in any registry and is referenced by --buildpack registry.example.com/bp/hello:0.0.1, by a ClusterBuildpack, or as a [[buildpacks]] entry in a builder.toml.
Assembling a builder
# builder.toml
description = "Platform builder for the example estate"
[[buildpacks]]
uri = "docker://registry.example.com/bp/hello:0.0.1"
[[buildpacks]]
uri = "docker://paketobuildpacks/go"
[[order]]
[[order.group]]
id = "paketo-buildpacks/go"
[[order.group]]
id = "example/hello-bp"
optional = true
[build]
image = "paketobuildpacks/ubuntu-noble-build"
[run]
[[run.images]]
image = "paketobuildpacks/ubuntu-noble-run"
mirrors = ["registry.example.com/mirror/ubuntu-noble-run"]
[lifecycle]
version = "0.21.18"
[[build.env]]
name = "BP_GO_TARGETS"
value = "./cmd/api"
suffix = "default" # default | override | append | prepend ("delim" required for the last two)
pack builder create registry.example.com/platform/builder:2026-09 --config builder.toml --publish
[build] and [run] are the current form; the older [stack] table (with id, build-image, run-image, run-image-mirrors) is still accepted and marked deprecated. The suffix on [[build.env]] is how a builder sets an estate-wide default that a buildpack can still override.
Language Notes
Java — the common accidental entry point
Spring Boot's build plugins run the CNB lifecycle directly, no pack required. For a great many teams this is their first and only contact with buildpacks.
./gradlew bootBuildImage
mvn spring-boot:build-image # or build-image-no-fork, to bind to the package phase
tasks.named("bootBuildImage") {
imageName = "registry.example.com/team/${project.name}:${project.version}"
builder = "paketobuildpacks/builder-noble-java-tiny:latest" // the current default
publish = true
environment["BP_JVM_VERSION"] = "21"
docker {
publishRegistry { username = providers.environmentVariable("REG_USER").get()
password = providers.environmentVariable("REG_TOKEN").get() }
}
}
Defaults worth knowing: image name is docker.io/library/<name>:<version>, pullPolicy is ALWAYS, publish is false, and trustBuilder is true for the official builders. The default builder moved from paketobuildpacks/builder:base to builder-jammy-base to builder-noble-java-tiny — three different strings in three release lines, so pin it explicitly if you care.
Paketo Java configuration is entirely environment-driven: BP_JVM_VERSION, BP_JVM_TYPE (JRE or JDK), BP_MAVEN_BUILD_ARGUMENTS, BP_GRADLE_BUILD_ARGUMENTS, BP_NATIVE_IMAGE=true for a GraalVM native build, and BPL_JVM_THREAD_COUNT at runtime. The Java buildpack also sizes the heap from the container's memory limit at launch, via an exec.d helper — which is a genuinely good default that a hand-written Dockerfile usually gets wrong.
Node.js
Detection keys off package.json. BP_NODE_VERSION pins the runtime, BP_NODE_RUN_SCRIPTS runs build scripts (--env BP_NODE_RUN_SCRIPTS=build), BP_NODE_PROJECT_PATH handles a monorepo subdirectory. NODE_ENV=production at build time is what prunes dev dependencies. A Procfile in the source overrides the detected start command.
Python
Paketo detects requirements.txt, Pipfile, pyproject.toml/poetry.lock, or a conda environment file. BP_CPYTHON_VERSION pins the interpreter. Google's builder and Heroku's have their own detection lists and their own supported version windows, which differ — this is one of the places where "buildpacks" is not a single behaviour. There is no first-class uv buildpack in Paketo at time of writing; a uv-managed project generally needs uv export to a requirements.txt in a pre-build step, or a custom buildpack.
Go
Detection needs go.mod. BP_GO_TARGETS selects which package(s) to build in a multi-binary repo (./cmd/api), BP_GO_BUILD_FLAGS passes flags, and BP_GO_VERSION pins the toolchain — otherwise the go directive in go.mod decides. Real capture:
Paketo Buildpack for Go Distribution 2.10.28
Candidate version sources (in priority order):
go.mod -> ">= 1.24"
Selected Go version (using go.mod): 1.26.7
Paketo Buildpack for Go Build 2.4.39
Running 'go build -o /layers/paketo-buildpacks_go-build/targets/bin -buildmode pie -trimpath .'
Note -buildmode pie -trimpath by default. Go is the natural fit for a tiny builder: nothing but the binary and CA certificates ends up in the image.
.NET
BP_DOTNET_FRAMEWORK_VERSION and BP_DOTNET_PUBLISH_FLAGS on Paketo; detection keys off *.csproj/*.fsproj/*.vbproj or a .sln. Available on Paketo's Ubuntu builders, Google's, and Heroku's; not on the UBI builders.
Buildpacks vs Dockerfiles vs apko/ko
The interesting comparison is not feature checklists, it is who owns which decision. For builder-by-builder detail on the Dockerfile side — buildx, Buildah, Kaniko, Stacker, apko, ko — see Container Image Building; for layer ordering, cache mounts, and base-image selection technique see Container Image Optimisation.
| Axis | Buildpacks | Dockerfile | apko / ko |
|---|---|---|---|
| Who owns the base image | The builder vendor | You | The apko config author (you, declaratively) |
| Who owns patching | The builder vendor publishes a new run image; you rebase | You, by rebuilding | The package repository; you rebuild the (cheap) image |
| Patching cost | Seconds, no rebuild, no retest | Full rebuild and revalidation | Fast rebuild, but still a rebuild |
| Reproducibility | Strong by default — fixed 1980 epoch, identical image IDs across rebuilds | Weak unless you work at it | Strong by design |
| Control over layers | None | Total | Declarative, package-granular |
| Debuggability of the build | Poor — opaque phases, third-party scripts | Good — comment out a RUN |
Good — the config is the manifest |
| Debuggability of the result | Depends on the run image variant; tiny has no shell |
Yours to decide | Typically no shell |
| Image size, same Go binary | 44.3 MB (measured, Paketo tiny) | ~9 MB (measured, FROM scratch) |
Comparable to scratch, plus an APK database |
| Polyglot estate | Excellent — one pipeline, many languages | Every team writes their own | Good, but you supply the build |
| Unusual dependencies | Buildpack, image extension, or give up | Trivial | Needs an APK |
Layer breakdown of that 44.3 MB image, to show where the premium goes: 27.1 MB run image, 9.01 MB application binary, 5.19 MB and 2.94 MB lifecycle launcher and helper layers, and a handful of sub-100 kB config and process-type layers. Measured 2026-09-05 against builder-jammy-tiny; the run-image half in particular drifts as the base is rebuilt, and was already 30.9 MB on a re-measurement four days later. The stable number is the roughly 8 MB of launcher and helper overhead — that is what CNB costs you over a scratch image, whatever the base is doing. (Both figures are on-disk sizes from the same local store, so they compare like for like; registry-reported compressed sizes are smaller for both.) The CNB overhead over a scratch image is roughly 8 MB of launcher plus whatever run image you chose. That is a defensible price for a shell, a CA bundle, and a rebasable base — and an indefensible one if you are shipping a static binary to a fleet and counting megabytes.
When buildpacks are the right answer: a platform team serving many application teams in several languages; an estate where OS CVE turnaround is a compliance metric; anywhere the number of Dockerfiles is larger than the number of people who understand them.
When they are not: a single well-understood service; anything needing unusual system libraries; anything where image size or exact layer control is a hard requirement; and any team that would rather debug a Dockerfile at 3am than a buildpack.
The two are not exclusive. A common pattern is buildpacks for the long tail of ordinary services and Dockerfiles for the handful that genuinely need them, with the same registry, scanning, and signing on the far side.
Quick Reference
| Task | Command |
|---|---|
| List suggested builders | pack builder suggest |
| Inspect a builder without pulling | pack builder inspect <builder> |
| Build from the current directory | pack build myapp --builder <builder> |
| Build from elsewhere | pack build myapp --path ./svc --builder <builder> |
| Push directly to a registry | pack build reg/myapp:tag --publish |
| Build-time environment | pack build myapp --env KEY=value --env-file ./build.env |
| Force specific buildpacks | pack build myapp --buildpack a,b |
| Registry-backed cache | pack build reg/myapp:tag --publish --cache-image reg/myapp-cache:main |
| Named volume cache | pack build myapp --cache 'type=build;format=volume;name=c' |
| Clear the cache | pack build myapp --clear-cache |
| Cross-architecture | pack build myapp --platform linux/arm64 |
| Record the resulting digest | pack build myapp --report-output-dir ./out |
| Inspect a built image | pack inspect-image myapp |
| Extract the SBOM | pack sbom download myapp --output-dir ./sbom |
| Rebase onto a patched run image | pack rebase myapp:tag --publish |
| Set a default builder | pack config default-builder <builder> |
| Trust a builder | pack config trusted-builders add <builder> |
| Add a run image mirror | pack config run-image-mirrors add <run-image> --mirror <mirror> |
| Package a buildpack | pack buildpack package reg/bp:1.0.0 --config package.toml --publish |
| Create a builder | pack builder create reg/builder:tag --config builder.toml --publish |
| Diagnose versions and APIs | pack report |
| Concept | Current spelling | Older form |
|---|---|---|
| Base images | build image, run image, targets | stack, mixins (deprecated at Platform API 0.12 / Buildpack API 0.10) |
| Phase order | analyze → detect → restore → build → export |
detect first, before Platform API 0.7 |
| Paketo general builder | paketobuildpacks/ubuntu-noble-builder |
builder-jammy-base; paketobuildpacks/builder:base is archived Ubuntu 18.04 |
| Buildpack API | 0.12 | — |
| Platform API | 0.15 | — |
| Process command | command = ["/path"] (array) |
string, before Buildpack API 0.9 |
Common Issues and Solutions
| Issue | Cause | Solution |
|---|---|---|
ERROR: No buildpack groups passed detection / failed with status code: 20 |
No group's non-optional buildpacks all passed. The message never says which file it wanted | Read the fail:/skip: list above the error. Confirm --path points at the project root, not the repo root. Check the marker file exists (go.mod, package.json, requirements.txt, pom.xml) |
| Wrong language buildpack chosen in a polyglot repo | Detection is first-match on the builder's ordered list, not best-match | Pin the group in project.toml or with --buildpack; inspect the order with pack builder inspect |
Warning: Builder is trusted but additional modules were added; using the untrusted (5 phases) build flow |
Any --buildpack/--extension beyond the builder's own contents drops to the five-container flow |
Expected and correct. Use --trust-extra-buildpacks only for buildpacks you control |
| Build slower than expected on a trusted builder | Untrusted flow, per above, or --pull-policy always (the default) re-pulling a 1.3 GB builder |
Check for the trust warning; set --pull-policy if-not-present where the builder is pinned by digest |
statfs /var/run/docker.sock: permission denied, or permission denied while trying to connect to the docker API at unix:///var/run/docker.sock with status code: 32, under rootless Podman |
pack bind-mounts the host /var/run/docker.sock into the build container regardless of DOCKER_HOST. Which error you get depends on what is at that path: statfs for a root-owned podman-docker symlink, a connect error for a real root-owned Docker socket |
systemctl --user start podman.socket, export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock", and add --docker-host=inherit to pack build. The --docker-host=inherit is the part that matters — it stops pack mounting the host socket at all |
--publish fails to authenticate although docker push works |
pack reads the Docker config file itself; it does not talk to the daemon's credential store, and a credential helper the daemon can reach may not be on pack's PATH |
docker login writing a plain auths entry, pack config registries add, or set DOCKER_CONFIG to a directory pack can read. In CI, prefer an explicit login step over inherited helpers |
--cache-image fails with a 403 while the app image pushes fine |
The cache repository is a separate repository needing its own push grant, and it must exist or be creatable | Grant push on <repo>-cache; remember --cache-image requires --publish |
rebase app image: new base image ... not found in existing run image metadata |
The target run image is not the recorded runImage.image or one of its mirrors, or the target data does not match |
Rebase onto the recorded run image, or register a mirror with pack config run-image-mirrors add. Reach for --force only for a genuinely equivalent image that has been renamed |
| Rebase "succeeded" and the app now segfaults | --force was used across incompatible bases — tiny to base, or a different distro version |
Never --force across base flavours or distro versions. That validation is the safety net |
| Image far larger than expected | The full builder, or a base run image where tiny would do |
Match the variant to the language. Go and GraalVM native → tiny; managed runtimes → base. Remember there is no full in the Noble generation — use the Apt buildpack |
No shell in the image, kubectl exec useless |
tiny/static run images are distroless-like by design |
Use an ephemeral debug container, or accept a base run image for the debuggability |
Runtime env var set with --env is missing at runtime |
--env is build-time only; pack build --help says so explicitly |
Set it in the orchestrator, or have a buildpack contribute env.launch/exec.d |
paketobuildpacks/builder:base still "works" |
It is an archived Ubuntu 18.04 image, unpatched since 2023 | Move to paketobuildpacks/ubuntu-noble-builder; the Paketo migration guide calls it a direct swap from builder-jammy-base |
Image creation date reads 1980-01-01 |
The reproducible-build default | Expected. Use --creation-time now if a policy insists, accepting the loss of bit-identical rebuilds |
Warning: Exporting to docker daemon … daemon uses containerd storage; performance may be significantly degraded |
The containerd image store, which is the default for fresh Docker Engine 29.0+ installs. Every pack build without --publish hits it |
Real but usually tolerable. --publish to a registry avoids the daemon export entirely; tracked upstream as pack#2272 |
no space left on device on /tmp during export, on a box with plenty of disk |
pack stages image tarballs through $TMPDIR. One build against a base builder wrote 973 MB into /tmp/imgutil.local.image.*, which overflows a default tmpfs sized to half of RAM |
export TMPDIR=/var/tmp/packtmp (or anywhere on real disk) before pack build. Worth setting unconditionally in CI |
| kpack never rebuilds after a new base image is published | ClusterStack/ClusterStore do not poll upstream registries |
Bump the digest from CI on a schedule; kpack only re-resolves images those resources already name |
| CNB GitHub Action fails with "unable to resolve action" | Major-only tags (@v6) do not exist for buildpacks/github-actions |
Pin the full semver, e.g. buildpacks/github-actions/setup-pack@v6.1.1 |
Related Topics
The following topics complement this cheatsheet and would be valuable additions:
- Container Image Building - The Dockerfile-side toolchain buildpacks replace: buildx, Buildah, Kaniko, Stacker, apko, and ko, and when each beats a builder
- Container Image Optimisation - Layer ordering, cache mounts, multi-stage technique, and base-image selection for the builds you keep writing by hand
- Container Registries - Where builders, buildpackages, run images, and cache images all live; mirrors, retention, signing, and attestation
- Grype and Syft - Consuming the SBOM CNB emits, and scanning the resulting image for vulnerabilities the buildpacks did not know about
- GitHub Actions - Workflow structure, OIDC registry authentication, and caching strategy for the pipeline that drives
pack - Kubernetes - Where kpack runs and what it reconciles; the deployment side of everything built above