TLDR:
- A CVE in your base image doesn't mean you have to rebuild the application container image. Most of the time you can rebase and leave the app layers untouched.
- Rebuild changes every layer's digest, so Kubernetes re-downloads gigabytes it already had. Rebase keeps most digests byte-identical, so the cluster nodes can skip the download.
- Docker Buildx and Buildpacks can cache interim build artifacts and rebase. Buildpacks ship rebase as a one-line command. With Docker you can get there too, but only if you design the Dockerfile for it and keep the build cache alive.
A CVE lands in the base image of one of your services. You do the responsible thing: bump the base, rebuild, likely run unit tests, push to the registry, run integration tests, redeploy. A few minutes later the cluster is green again — and your registry egress graph has a fresh spike on it, because every node just pulled down a couple of gigabytes of layers it already had.
Nothing in your application changed. The JAR and its dependencies are byte-for-byte what they were before. But the rebuild handed every layer a new digest, so as far as Kubernetes is concerned, none of those blobs exist yet. They all need to be downloaded again.
There were three ways to ship that one-package patch, and you reached for the most expensive one. So let's untangle the options: a full rebuild, a cached rebuild, and a rebase. Each has its own use case and preconditions, and each behaves a little differently depending on whether you build with Dockerfiles or with Buildpacks.
Rebuild and rebase are not the same operation
The first thing worth clarifying is the difference between rebuilding and rebasing.
Rebuilding means running the image-building process again to produce a new application image. Conceptually it starts from the build inputs:
source code + build configuration + build-time environment + runtime base image → build → new application image
Running a rebuild doesn't mean every step executes from scratch. Both Docker BuildKit and Cloud Native Buildpacks (CNB) support caching, so previous results can be reused when they're still valid. We will talk about the intricacies of caching later on in this article. But for now, with a good cache, a rebuild can be extremely fast. That gives us two types of rebuild:
- Cold or full rebuild — the cache is missing, invalid, or deliberately ignored.
- Warm or cached rebuild — some or most of the previous work gets reused.
To force a full rebuild with Docker, you need two options:
docker build --pull --no-cache .
--no-cache downloads fresh dependencies; --pull downloads a fresh base image. Use them separately or together as needed. There's also --no-cache-filter if you want to invalidate specific instructions and keep the rest. By the way, if you pre-download all dependencies to a local environment, you will be able to build an image offline.
With Buildpacks, --no-cache alone may not be enough. The reason: buildpacks construct an image as a set of layers that are placed on top of the run image (more detailed explanation of this concept is below). So, unchanged launch layers can be reused straight from the previous image in the registry without ever being downloaded to the build host. So if the requirement is to use absolutely nothing from the previous build, a genuinely clean CNB build should also avoid finding the previous application image, for example, by building to a fresh image reference that no prior CNB image occupies.
Rebasing starts from a different premise. The application does not get built again at all. Instead of going back to the source and running the build, a rebase starts with an already-built application image. The upper layers aren't recreated but lifted from the existing image and placed on top of a new, compatible runtime base.
Rebuild vs rebase
That only works for specific cases. If the thing that needs updating belongs to the replaceable runtime base, and the patch is compatible with the running application, a rebase is possible. The usual case is OS patches applied by the base image maintainer. But anything in the runtime base can be swapped this way. Everything above that boundary is retained.
The runtime itself (the JRE, say) depends on where it lives. If it's part of the runtime base image, a compatible rebase replaces it along with the rest of the base. If it sits in a layer above the rebase boundary, a rebase preserves it, and updating it means a new build.
One more distinction: rebasing with Buildpacks is not the same as rebasing with Dockerfiles. Cloud Native Buildpacks provide rebase as an explicit lifecycle operation on an existing application image. BuildKit can pull off rebase-like, layer-preserving optimizations, but only under the right Dockerfile, cache, and backend conditions.
|
Operation |
Dockerfile / BuildKit |
Cloud Native Buildpacks |
|---|---|---|
|
Cold build |
Yes |
Yes |
|
Cached build |
Yes |
Yes |
|
First-class rebase without build |
Not generally |
Yes |
When a full rebuild may be required
A cold rebuild happens when the build cache has been lost or invalidated. That's an undesirable situation you should be trying to avoid. But a cold rebuild with no cache available is not the same thing as a full rebuild where you deliberately ignore the cache.
Sometimes you want the second one even when you have a perfectly good cache. The reason to force it is often weaker with Buildpacks, because they can invalidate semantic layers selectively rather than throwing everything away. Still, here are the situations that call for it.
1. The cache doesn’t work or breaks in CI.
In CI, a cache can disappear because of ephemeral runners, or get pruned. The misconfiguration of external cache import is also possible. In that situation, a full rebuild from a prepared runtime image may be a cleaner approach as the build doesn't depend on the build state from previous runs.
2. You no longer trust the cache.
A CI compromise, suspected cache poisoning, a corrupted cache, questionable provenance. Reusing the cache in any of those cases defeats the whole point of producing a clean artifact, so you rebuild from trusted sources instead. This applies equally to Dockerfiles and Buildpacks.
3. You want to prove the build is reproducible.
Building without cache every so often is a cheap way to verify that all dependencies and inputs are actually declared, not accidentally inherited from a cached layer. Again, equally true for both.
4. You changed the toolchain or dependency-resolution policy.
Runtime upgrades (JDK 21 to JDK 25), a Maven repository-policy change, a compiler migration, an OS migration (Ubuntu to Alpaquita), a significant change in how dependencies resolve. Clearing everything isn't strictly required — both Buildpacks and Dockerfiles can change some layers and reuse others — but a clean rebuild is still the safe move here to catch regressions before your users do.
|
Goal |
Likely operation |
|---|---|
|
Patch a compatible run image, nothing else changed |
Rebase |
|
Normal application release |
Cached rebuild |
|
Refresh dependencies |
Selective or clean rebuild |
|
Suspected compromised/untrusted cache |
Clean rebuild |
|
Validate reproducibility/build completeness |
Clean rebuild |
|
Major toolchain/build-environment migration |
Often clean build |
|
Build-stage components only |
Rebuild helps build security and consistency, but it does not patch the deployed runtime |
Caching: how much of the rebuild can you skip?
Caching reduces time required for a rebuild by reusing the image layers that haven't changed. Dockerfiles and Buildpacks cache different abstractions, so working with them looks different. Let's take them one at a time.
Docker build cache
When BuildKit executes a Dockerfile, it walks the instructions and checks whether it already has a reusable result for each one. Take this multistage build:
FROM bellsoft/liberica-runtime-container:jdk-25-musl AS builder
RUN apk add nodejs npm
WORKDIR /app
COPY . /app/hello-java
RUN cd hello-java && ./mvnw package
FROM bellsoft/hardened-liberica-runtime-container:jre-25-musl
WORKDIR /app
COPY /app/hello-java/target/hello-java-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/app.jar"]
Notice the two base images. Because the build stage calls apk add, it has to start from the plain liberica-runtime-container: hardened images ship without a package manager, so there's no apk to run. The final stage installs nothing, which is why it can take the smaller, hardened runtime.
Each filesystem-changing Dockerfile instruction, such as RUN, COPY, or ADD, creates a layer. A cache hit on the ./mvnw package step means Maven doesn't run at all: BuildKit reuses the previous result. The builder keeps this cache internally, so locally you don't have to enable anything.
The catch: change a layer, and every layer that depends on it has to be rebuilt too. Change the application source, and everything from the COPY onward is invalidated and runs again.
Dockerfile authors have a lot of influence over how often that happens. For example, you can order your instructions from least to most frequently changing, so a low-level change doesn't invalidate everything stacked above it. A .dockerignore keeps the build context small, so irrelevant files don't sneak in as inputs to a broad COPY. And a cache mount like the Maven local repository means that even when a RUN is invalidated and Maven runs again, it still finds its previously downloaded artifacts instead of pulling them all from scratch. Although with maven, it’s not that simple. It is not a good practice to include Maven cache into a container image, so in CI, Maven repo is usually set up separately. That means a separate set of caching strategies and a separate set of potential issues.
The internal cache is automatic locally, so you rarely think about it. CI is where it gets interesting. CI/CD runners are usually ephemeral with no persistence between them, which means the local cache doesn't survive to the next run. Docker handles this with external cache backends (registry, local filesystem, inline, GitHub Actions) that you export and import explicitly:
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
--push \
-t registry.example.com/myapp:latest \
.
--cache-from tells Docker where to read the cache. This registry is provided by you, Docker does not conjure it out of nowhere. --cache-to says where to write the updated cache. But the repo you write cache to is not an infinite archive. Per the Docker documentation, exporting repeatedly to the same location overwrites what was there, so branches and workflows may need unique cache scopes. Finally, mode=max exports results from intermediate stages, which matters for multistage builds.
Note that external caching is not automatically enabled. If you don ’t specify these options, BuildKit will try to use its internal cache, which might have already disappeared as we moved to the next ephemeral runner.
What can go wrong with the Docker build cache
All that control has a downside. It's easier to make a mistake that leaves the cache missing, stale, or invalidated. Here's the short catalog of failure modes and what to do about them.
|
Problem |
What it means |
What happens to the build |
Possible mitigation |
|---|---|---|---|
|
Cache unavailable |
Builder can't find previous results |
Build runs without the cache |
Persistent builder or external cache |
|
Cache not imported |
Remote cache exists but CI doesn't use --cache-from |
Build runs without the cache |
Configure import explicitly |
|
Cache overwritten |
Several workflows write the same cache reference |
Some cache entries may be lost, leading to cache misses and slower builds |
Give branches/workflows separate cache scopes |
|
Cache invalidated |
Relevant build inputs changed |
Build runs without the cache |
Improve Dockerfile layering; expected when real inputs change |
|
Excessive invalidation |
Irrelevant files affect a broad COPY |
Cache effectiveness decreases even if the changed file is irrelevant to the app. |
.dockerignore, narrower COPY, better instruction ordering |
|
Stale cached RUN |
External repository changed but the cache key didn't |
The build uses the cache, but the final image can contain outdated packages or dependencies. |
Selective --no-cache-filter, explicit versioning |
|
Cache evicted/pruned |
Local or CI retention removed it |
Build runs without the cache |
Persistent/registry backend and retention planning |
|
Cache not trusted |
Provenance or integrity of previous build state is questionable |
Potential integrity and security risk if this cache is reused |
Perform a clean rebuild |
The stale RUN row deserves a closer look, because it's the opposite problem to a missing cache and no less annoying. Docker doesn't query the package repository when it checks the cache. In our example Dockerfile:
RUN apk add nodejs npm
If that instruction is unchanged, BuildKit reuses the old cached result even when newer packages are available upstream. Base images behave the same way. Not only annoying, but dangerous. We planned to update the images when the patches for known CVEs appear, but that doesn’t happen!
Docker gives you --no-cache, --no-cache-filter, and --pull for exactly this. With the help of the package manager used by your image, you can bake the behavior into the Dockerfile so the layer always reruns:
RUN apk add --no-cache nodejs npm
Or you can target it at build time without nuking the rest of the cache and use for downloading a fresh base image:
docker buildx build \
--pull \
--no-cache-filter add \
.
Buildpacks cache
Cloud Native Buildpacks think about the cache differently. You don’t need to configure the cache explicitly, it is configured by the buildpack itself. The only thing it needs is the access to the repo: that is the same access given to the builder and published final image.
Also, buildpacks don't cache arbitrary build instructions. A buildpack creates named semantic layers and attaches metadata describing them. A Java image might have these:
- JDK
- Maven dependencies
- Application dependencies
- JRE
- Application
Each buildpack reasons about its own layers and decides independently whether each one is still reusable. There are two places a layer can come from: cached buildpack layers and launch layers from the previous application image:
Build cache / cache image
Build cache / cache image
├── JDK
├── Maven downloads
└── other cache-enabled build dependencies
Previous app image
├── JRE launch layer
├── dependency launch layer
└── other reusable launch layers
Because there are two sources, Buildpacks can reuse useful layers even when one of them is missing.
The other big difference: you don’t need to burden the developers — or any team, for that matter — with configuring the caching strategies by hand. But at the same time, teams have a good control over the cache lifecycle. The buildpack author owns the layer structure and the invalidation behavior. Buildpacks read the metadata they wrote during the previous build and consider application-file changes, environment changes, buildpack-version changes, and dependency-tree changes when deciding whether a layer still holds. A newer dependency gets selected when the buildpack's dependency metadata changes: typically after you update the buildpack or the builder.
The last bit is worth explaining in detail. Buildpacks don't poke upstream repositories on every build looking for newer OS or runtime versions. Runtime-base updates come from resolving the configured run-image reference, and pack build uses a pull policy to control that. The default --pull-policy always checks the registry for the current image behind the reference; if-not-present or never keep using a locally available image instead.
Runtime updates follow the same logic. A Java buildpack carries dependency metadata that maps a request like "Java 21" to a concrete JRE version, so a newer JRE is usually picked only after the buildpack or builder itself has been updated. Cache invalidation then follows the selection: if the previous JRE layer holds 21.0.7 but the current buildpack wants 21.0.8, that layer is no longer reusable.
In addition, CNB lets buildpacks modify cached build dependencies before reusing them, instead of treating a dependency cache as all-or-nothing. That's finer-grained control over the dependency graph than a Dockerfile gives you. But how is it possible? The answer is in different approaches to containerization that buildpacks and Dockerfiles give you. With Dockerfiles, you get various parts of a bigger mechanism without specialized semantics, but with freedom to substitute everything and assemble whatever you want. Buildpacks are created with a goal to know and containerize various projects correctly. Some customization is taken away, but so are the low level tasks.
Dockerfiles vs Buildpacks
So buildpacks reduce the maintenance burden otherwise placed on application developers and leave fewer opportunities for misconfiguration. What's left is application-independent and for the platform team to decide:
- which builder and buildpacks are used, and their versions
- application dependency declarations
- build environment and configuration
- cache persistence in CI
- previous-image availability
- whether the cache is deliberately cleared
CNB trades application-level control for centrally maintained semantics. In practice, the two things you'll reach for are creating a cache image in CI because the local cache isn't persisted between ephemeral hosts and clearing the cache when you need to:
pack build registry.example.com/myapp:latest \
--builder bellsoft/buildpacks.builder:musl \
--cache-image registry.example.com/myapp:build-cache \
--publish
--cache-image works together with --publish to preserve build-optimizing layers across hosts. And --clear-cache invalidates the cache when you want a clean slate.
—
Neither model is better than the other. They're just different. A perfectly tuned BuildKit build can be extraordinarily fast. On the other hand, Buildpacks offer a higher-level cache model, so sensible caching comes for free without application teams hand-designing Dockerfile optimization patterns.
Caching shaves time off a rebuild. Rebase asks whether you need the rebuild at all.
Rebase: not rebuilding in the first place
When you rebase, you swap the runtime image underneath the application and leave every layer above it exactly as it was. The important part: the digests of those reused layers stay the same, because the layers stay byte-for-byte identical.
That byte-identity is what makes rebasing worth the trouble in a Kubernetes cluster. Kubernetes documents that the container runtime can notice image layers already present on a node and skip downloading them again, but only when the upper layers are byte-identical and have the same digest. So, when you push the rebased image and update the workload’s image reference like this:
kubectl set image deployment/myapp \
myapp=registry.example.com/myapp:v2
Or in the YAML:
spec:
template:
spec:
containers:
- name: myapp
image: registry.example.com/myapp:v2
And apply the changes:
kubectl apply -f deployment.yaml
Kubernetes will create the replacement pods. Then, kubelet will ask the container runtime for the new image. The new image that you’ve pushed gets resolved, and the runtime downloads only layers that are not already cached locally — in our case, the base layer.
So no more pointless 2 GB re-download from the opening, i.e, pod provisioning will be faster.
Rebasing with Buildpacks
Cloud Native Buildpacks give you rebasing out of the box. The CNB documentation describes it as placing the existing application layers on top of a new version of the runtime base image, without rebuilding the application. The rebase tool inspects the application image, checks whether a newer base image exists, and updates the image's layer metadata to reference the new base. No build cache, no source code, no Dockerfile — the previous image alone is enough.
Say we built a Java application image with the BellSoft builder, which uses BellSoft Hardened Images as a base, and the pack CLI:
pack build hello-java \
--path . \
--builder bellsoft/buildpacks.builder:musl
Some time later, the underlying run image gets an important OS patch. To pick it up, we rebase:
pack rebase hello-java:latest \
--run-image bellsoft/buildpacks.hardened-run:musl \
--pull-policy always
This rebases hello-java:latest and writes the result back under the same tag. --run-image names the run image to move onto. --pull-policy isn't strictly required: it defaults to always, which pulls the current image from the registry. But if your settings pin it to never or if-not-present somewhere, override it here so you don't rebase onto a stale cached image.
If you'd rather produce a new image than overwrite the old one, name it and point --previous-image at the original:
pack rebase hello-java:rebased \
--previous-image hello-java:before-rebase \
--run-image bellsoft/buildpacks.hardened-run:musl \
--pull-policy always
Rebasing with Dockerfiles
You can rebase with Dockerfiles too, but the path is bumpier. First, we need to change the Dockerfile — that already counts as manual work. But simply changing the FROM line to a new runtime version and running docker build is technically a rebuild, even if some layers come from the cache. A cache hit isn't what we're after this time because we need reuse of layers with the exact same digests.
Docker has one feature that makes this possible:
COPY --link
--link keeps the copied content in an independent layer. When the base or preceding layers change, BuildKit reuses that linked layer and merges it onto the new base, rather than rebuilding it against the changed filesystem state.
FROM bellsoft/hardened-liberica-runtime-container:jdk-21-musl AS build
WORKDIR /app
COPY . .
RUN ./mvnw clean package
FROM bellsoft/hardened-liberica-runtime-container:jre-21.0.7-musl AS runtime
COPY --link --from=build /app/target/classes/ /app/
ENTRYPOINT ["java", "-cp", "/app", "com.example.HelloServer"]
Here we didn't just copy the compiled classes into the final image, we used --link, so BuildKit builds that layer independently instead of tying it to the previous filesystem state. Build the image and export the cache:
docker buildx build \
--load \
--cache-to type=local,dest=experiment/docker-cache,mode=max \
--tag hello-java:before \
.
--cache-to type=local,dest=experiment/docker-cache writes reusable build-cache records to a directory after the build. If you're only building locally you don't need --cache-to at all, since BuildKit caches internally. Exporting is for taking the cache outside the builder so a fresh CI runner can import it later — see the caching section above.
Now change only the runtime base:
FROM bellsoft/hardened-liberica-runtime-container:jre-21.0.11-musl
And rebuild, importing the cache you exported:
docker buildx build \
--load \
--cache-from type=local,src=experiment/docker-cache \
--tag hello-java:after \
.
Two things are worth calling out about what just happened.
First, the source of truth. Docker doesn't reach for the previous image the way Buildpacks do. BuildKit runs another build and recognizes that the COPY --link result from the earlier build is still reusable despite the changed base. Buildpacks treat that previous image as their source of truth; Docker has no equivalent step and leans entirely on the cache to find the reusable layer.
Second, that cache has to be findable. --link lets BuildKit reuse the previously built layer, including through --cache-from, but only if it can locate that linked result, whether in the same builder's local cache or in an external one. On an ephemeral CI runner you'd import an exported cache:
--cache-from type=registry,ref=...
And here's the emphatic part: if the cache is absent or invalidated, the rebase simply doesn't happen. You get a full rebuild, new digests and all, right back to the 2 GB re-download.
So Docker can achieve manifest-level rebasing, but its fast, byte-preserving path relies on the cache being there. You have to design for it: use --link, avoid later filesystem instructions that break the independent-layer design, and configure CI caching so the optimization survives a move to a new builder.
Buildpacks rebase uses the old application image itself as the source of preserved layers and doesn't depend on the earlier build cache at all. Rebaseability is part of the CNB image contract, so you don't engineer a Dockerfile around it or fight the caching logic. That's the whole point of CNB's design: container-build knowledge lives in one place instead of every application team maintaining its own.
|
Requirement |
BuildKit COPY --link rebase |
CNB rebase |
|---|---|---|
|
Existing application image |
Usually useful |
Required |
|
Source/build context |
Usually required |
No |
|
Dockerfile |
Required |
No |
|
Build cache metadata |
Required for exact layer reuse |
No |
|
Cached build stage |
Required to avoid a Maven rebuild |
No |
|
New runtime base |
Required |
Required |
|
Exact preservation of upper blobs |
Conditional on cache reuse |
Structural guarantee |
Conclusion
A cold rebuild gives you a clean start. A warm one reuses whatever work is still good. Rebase goes further and skips the application build entirely. Which one fits depends on what changed, what cache you have on hand, and whether you're on Dockerfiles or Buildpacks.
The differences are easiest to feel in practice. Build an application image once, then update it three ways (a cold rebuild, a cached rebuild, and a rebase) and compare the build times and the layer digests. You'll see immediately how much work each path actually does.
And if the change you keep shipping is a base-image CVE patch, that's the case rebase was made for. The BellSoft hardened Paketo builder gives you a run image built for exactly this: pack rebase onto the continuously patched hardened run image, and the application layers never move. This way, Kubernetes downloads only what genuinely changed.








