Images That Patch Themselves: Cloud Native Buildpacks on VKS
On 21 October 2026, PHP shipped emergency fixes for PHP2Shell, two flaws that let anyone run code on a PHP web server without logging in. Patched PHP buildpacks were out the same day.
That last line got me thinking. A patched buildpack doesn’t patch a running app: every app built on the old runtime still has to be rebuilt and redeployed before it’s safe. But when the runtime is a layer the platform supplies, rather than something each app bakes in for itself, the rebuild and the redeploy can happen without anyone asking, and one fix reaches every app at once. Patching becomes routine, and an emergency like PHP2Shell becomes a quick response instead of a scramble.
With Dockerfiles it’s every team for itself, and I know the job. Planespotter is one of my Python microservices apps, and it has four Dockerfiles. Three of them are the same file today: python:3.13-slim, pip install, copy app/, useradd, CMD. Nothing keeps them that way. Which Linux, which Python build, which user the process runs as: each file decides for itself, and every copy is one more to keep in step, in every app. When a CVE lands in that base image, I patch all three by hand and wait for CI to rebuild them.
Buildpacks make patching a policy instead. The platform decides the base once, patches it once, and every app built on it gets the same fix.
So I moved Planespotter to Cloud Native Buildpacks on VMware vSphere Kubernetes Service (VKS), with kpack building its images inside the cluster and Flux deploying them, to see whether patching could work that way. It can. As the developer, I now get:
- A cluster of my own from one manifest, with load balancers and persistent storage already wired in.
- No Dockerfile. A start command and a Python version in its place.
- Patches I never apply. An operating system fix rebases every image in seconds; a runtime fix, the kind PHP2Shell needed, rebuilds every image that uses it in minutes. The pods roll, and I find out from a commit in my own repository.
- Proof I can check myself: an SBOM in every image, and a Grype scan from my laptop before and after each patch.
Patch Day, From My Seat
Planespotter runs Python 3.13, and a patch release fixed a critical flaw in its ssl module. One Sunday morning, a one-line platform commit moved the cluster’s builder to the CPython buildpack that carries the fix.
I didn’t touch anything. A couple of minutes later kpack, which builds the images inside the cluster, had spotted that all three of my images were built with the old buildpack and was rebuilding them. A couple more, and commits started arriving in my repository:
$ git log --format='%h %an %s' -3 buildpacks
58a62e1 fluxcdbot Roll to the images kpack pushed
8e92147 fluxcdbot Roll to the images kpack pushed
43c1719 fluxcdbot Roll to the images kpack pushed
They’re from Flux. As each rebuild finished, Flux wrote the new image’s digest into my app’s manifests, committed it, and rolled the pods. About five minutes after the platform commit, all three were rolling onto the patched Python. I found out from git log.
The rebuilds, and one of Flux's commits
$ kubectl get builds -n planespotter-buildpacks \
-o custom-columns='BUILD:.metadata.name,REASON:.metadata.annotations.image\.kpack\.io/reason,STARTED:.metadata.creationTimestamp,FINISHED:.status.conditions[?(@.type=="Succeeded")].lastTransitionTime' \
| grep -E 'BUILD|BUILDPACK'
BUILD REASON STARTED FINISHED
adsb-sync-build-5 BUILDPACK 2026-10-25T09:14:04Z 2026-10-25T09:15:31Z
api-server-build-5 BUILDPACK 2026-10-25T09:14:04Z 2026-10-25T09:16:46Z
frontend-build-6 BUILDPACK 2026-10-25T09:14:04Z 2026-10-25T09:16:28Z
$ git show 58a62e1
Roll to the images kpack pushed
api-server: sha256:0ce21b3276c28e70ce45cae63382a53ba37a1716d4ca9bda933ed85f23fba5c8 -> sha256:adb1bda69ddcbe436890531a0b7cfe3698a2c66901fbf992465c3ce95c1d6a16
…
- digest: sha256:0ce21b3276c28e70ce45cae63382a53ba37a1716d4ca9bda933ed85f23fba5c8 # {"$imagepolicy": "planespotter-buildpacks:api-server:digest"}
+ digest: sha256:adb1bda69ddcbe436890531a0b7cfe3698a2c66901fbf992465c3ce95c1d6a16 # {"$imagepolicy": "planespotter-buildpacks:api-server:digest"}
The commits are 43c1719, 8e92147 and 58a62e1. Every image’s SBOM now names the patched CPython, with the same Python packages as before.
The operating system patch, earlier that morning, was quicker still. A platform commit had moved the builder to a patched Ubuntu run image. This time kpack didn’t rebuild anything. It rebased each image, swapping the Ubuntu layers underneath my code in the registry, in about twenty seconds for all three. Flux’s commits followed a couple of minutes later, and every Ubuntu CVE that had a fix, a high-severity OpenSSL one among them, was gone from my images.
The rebases
$ kubectl get builds -n planespotter-buildpacks -o custom-columns=… | grep -E 'BUILD|STACK'
BUILD REASON STARTED FINISHED
adsb-sync-build-4 STACK 2026-10-25T08:58:12Z 2026-10-25T08:58:31Z
api-server-build-4 STACK 2026-10-25T08:58:11Z 2026-10-25T08:58:31Z
frontend-build-5 STACK 2026-10-25T08:58:11Z 2026-10-25T08:58:31Z
Flux’s commits were 1655a9c and b9a6509. The SBOM inside each image came through byte for byte the same.
Two patches, two platform commits, and not one change to my app. With Dockerfiles, each would have meant setting CI off for every image, waiting for it, and then deploying. Every digest Flux has committed is in the kustomization’s history.
Check It Yourself
I didn’t take patch day on trust, and you needn’t either. Two things let anyone check it from a laptop: the bill of materials inside each image, and a vulnerability scanner.
The SBOM. As they build, the buildpacks write a software bill of materials for every layer they add, and it ships inside the image as a layer of its own, in CycloneDX, SPDX and Syft formats. I didn’t add a step to make it. pack sbom download pulls it out:
pack sbom download ghcr.io/darrylcauldwell/planespotter-buildpacks/api-server@sha256:739be236… --output-dir sbom
The files it writes, one per layer and format
$ find sbom -name 'sbom.*.json' | sort
sbom/layers/sbom/launch/paketo-buildpacks_ca-certificates/helper/sbom.syft.json
sbom/layers/sbom/launch/paketo-buildpacks_cpython/cpython/sbom.cdx.json
sbom/layers/sbom/launch/paketo-buildpacks_cpython/cpython/sbom.spdx.json
sbom/layers/sbom/launch/paketo-buildpacks_cpython/cpython/sbom.syft.json
sbom/layers/sbom/launch/paketo-buildpacks_python-package-managers-run/packages/sbom.cdx.json
sbom/layers/sbom/launch/paketo-buildpacks_python-package-managers-run/packages/sbom.spdx.json
sbom/layers/sbom/launch/paketo-buildpacks_python-package-managers-run/packages/sbom.syft.json
sbom/layers/sbom/launch/sbom.legacy.json
For the api-server, the CPython layer’s SBOM names the exact Python build, and the packages layer’s names every Python package it runs, down to the dependencies of its dependencies.
What the SBOM leaves out matters as much. It covers what the buildpacks installed, CPython and the Python packages; the Ubuntu packages come from the run image, and a scanner reads those from the image itself. That split is what lets a scan tell the two kinds of patch apart. After a rebase the operating system findings are gone and the SBOM hasn’t changed. After a rebuild Ubuntu is the same and the SBOM names a newer Python.
The scan. I used Grype, from Anchore, who also make the Syft library Paketo uses to write those SBOMs. It scans an image straight from the registry and recognises the CPython Paketo installs, so the critical ssl flaw shows up before the patch and is gone after it:
grype ghcr.io/darrylcauldwell/planespotter-buildpacks/api-server@sha256:739be236…
Here’s the api-server at each stage of patch day, grouped by where each finding came from:
| api-server | CPython layer | Ubuntu packages |
|---|---|---|
| As first built | 11 CVEs: 1 critical, 2 high | 76 CVEs, 34 with a fix, 1 high |
| After the operating system patch | unchanged | 42 CVEs, none with a fix |
| After the Python patch | 3 CVEs, medium and low | unchanged |
Grype's critical and high rows for the api-server as first built, 5 of its 136
$ grype ghcr.io/darrylcauldwell/planespotter-buildpacks/api-server@sha256:739be236… | grep -E 'NAME|Critical|High'
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK
python 3.13.15 3.10.22, 3.11.17, 3.12.15, *3.13.16, ... binary CVE-2026-19445 Critical 0.4% (36th) 0.4
python 3.13.15 3.10.22, 3.11.17, 3.12.15, *3.13.16, ... binary CVE-2026-19553 High 0.4% (33rd) 0.3
libssl3t64 3.0.13-0ubuntu3.15 3.0.13-0ubuntu3.16 deb CVE-2026-84782 High 0.4% (32nd) 0.3
openssl 3.0.13-0ubuntu3.15 3.0.13-0ubuntu3.16 deb CVE-2026-84782 High 0.4% (32nd) 0.3
python 3.13.15 3.10.22, 3.11.17, 3.12.15, *3.13.16, ... binary CVE-2026-82049 High 0.2% (7th) 0.1
Every digest stays in the registry, so you can scan the before and after yourself: api-server, frontend and adsb-sync are public.
Moving Off Your Dockerfiles
Getting there meant answering once, somewhere else, every question my Dockerfiles answered three times over. I built Planespotter’s images in GitHub Actions, each Dockerfile with buildx for amd64 and arm64, and pushed them to GHCR. Here’s where each answer went:
| Dockerfile | Buildpacks | |
|---|---|---|
| Base image | python:3.13-slim, named in each Dockerfile | the platform’s run image |
| Python | whichever 3.13 that image carries when CI runs | the platform’s CPython buildpack, at the version project.toml asks for |
| Node for the CSS | a node:22-slim build stage | the Node buildpacks, which leave no Node in the image |
| Dependencies | pip install in each Dockerfile | the package buildpacks, from a pinned requirements.txt |
| User | useradd and USER | non-root without asking |
| Start command | CMD | Procfile |
| Build | CI on every push, several minutes each time | kpack in the cluster, on a commit or a patch |
| Patch | every team rebuilds its own images | one platform commit |
| SBOM | none | in every image |
You don’t have to switch in one go. I ran Planespotter’s Dockerfile-built and buildpack-built images side by side on the same cluster while I compared them, each in its own namespace, deployed from the same Kubernetes manifests.
What You Write
A buildpack joins a build only when it recognises something in the source, a requirements.txt or a package.json, which is how one builder serves all three of Planespotter’s services. Here’s everything I wrote for each of them: the things a buildpack can’t work out for itself. It’s all on Planespotter’s public buildpacks branch.
A start command. Buildpacks ignore a Dockerfile’s CMD, so each service got a one-line Procfile:
web: uvicorn app.main:app --host 0.0.0.0 --port 8000 --timeout-graceful-shutdown 30
That’s the api-server’s. The frontend’s is the same on port 8080. adsb-sync polls OpenSky for aircraft positions rather than serving requests, so it declares a worker:
worker: python -m app.main
A project.toml. Paketo defaults to Python 3.11, and Planespotter runs 3.13:
[_]
schema-version = "0.2"
[[io.buildpacks.build.env]]
name = "BP_CPYTHON_VERSION"
value = "3.13.*"
[[io.buildpacks.build.env]]
name = "BP_ENABLE_PACKAGE_MANAGERS"
value = "true"
The second setting switches on Paketo’s new Python package-manager buildpacks: one pair handles pip, Poetry, Pipenv, uv and conda, so whichever tool your team uses, the same builder builds it.
The frontend has a Node build step, where Tailwind compiles its CSS before Python runs the app. One more setting tells the Node buildpacks which package.json script to run:
[[io.buildpacks.build.env]]
name = "BP_NODE_RUN_SCRIPTS"
value = "build:css"
Node does its job at build time and stays behind. An image holds only the layers its buildpacks mark for run time, and the frontend’s SBOM directory shows which those are:
$ ls -1 sbom/layers/sbom/launch
paketo-buildpacks_ca-certificates
paketo-buildpacks_cpython
paketo-buildpacks_python-package-managers-run
sbom.legacy.json
CA certificates, CPython and the Python packages: no Node and no node_modules. Its packages SBOM does name the npm packages in package-lock.json, the tools that built the CSS.
Pinned requirements. This is the one that isn’t obvious. Paketo writes the Python packages into the SBOM from requirements.txt, and lists a package only where the file pins its version. So each service now pins every package it installs, dependencies included, as the api-server’s does:
fastapi==0.162.0
uvicorn[standard]==0.54.0
sqlalchemy[asyncio]==2.1.6
asyncpg==0.32.0
redis==8.1.0
…
Left as a range, such as fastapi>=0.128.0, a package still installs but goes missing from the SBOM.
A kpack Image. This tells kpack what to build, from where, with which builder, and where to push the result (all three):
apiVersion: kpack.io/v1alpha2
kind: Image
metadata:
name: api-server
namespace: planespotter-buildpacks
spec:
tag: ghcr.io/darrylcauldwell/planespotter-buildpacks/api-server
serviceAccountName: builder
builder:
kind: ClusterBuilder
name: python-node
source:
git:
url: https://github.com/darrylcauldwell/planespotter
revision: buildpacks
subPath: api-server
build:
env:
- name: PIP_INDEX_URL
value: http://packages.mirror.svc/pypi/simple
- name: PIP_TRUSTED_HOST
value: packages.mirror.svc
All three services live in one repository, so each Image names its folder with subPath, and a commit to the frontend rebuilds only the frontend. The build settings point pip at the platform’s package mirror instead of the public Python Package Index. Most companies run Artifactory or Nexus for that; the settings are the same, and only the host changes. The frontend’s Image adds NPM_CONFIG_REGISTRY for its Node packages.
An ImagePolicy. This is what turns a new image into a commit. kpack pushes every build, rebuild or rebase under the same tag, latest, and Flux follows the digest behind it (all three, with the automation):
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: api-server
namespace: planespotter-buildpacks
spec:
imageRepositoryRef:
name: api-server
filterTags:
pattern: '^latest$'
policy:
alphabetical: {}
digestReflectionPolicy: Always
interval: 1m
When the digest changes, Flux writes it at the policy’s marker in the kustomization that deploys the api-server, and commits:
images:
- name: planespotter-api
newName: ghcr.io/darrylcauldwell/planespotter-buildpacks/api-server
newTag: latest
digest: sha256:0ce21b32… # {"$imagepolicy": "planespotter-buildpacks:api-server:digest"}
An ImageRepository per image tells Flux where to look, and one ImageUpdateAutomation names the branch the commits go to. The Deployments run each image by digest, so what’s running is always what the branch says.
That’s all of it. Postgres and Valkey stay as their vendor images: buildpacks are for the code you write.
The first builds of all three ran side by side in under two minutes. After that, a commit rebuilds only what it touches: when I changed the frontend’s code, kpack rebuilt the frontend alone, reused its CPython and package layers, and replaced only the app’s.
There is a cost. The images are about two and a half times the size of the Dockerfile-built ones, and Paketo’s CPython layer is most of the difference: on its own it outweighs the whole python:3.13-slim image twice over.
What VKS Gives You
All of this runs on a cluster I created myself. My starting point was a vSphere Namespace provisioned for the app, and a vcf CLI login. kubectl shows what the namespace offers:
$ kubectl get virtualmachineclasses -n planespotter-dev
NAME CPU MEMORY
best-effort-medium 2 8Gi
best-effort-small 2 4Gi
$ kubectl get kubernetesreleases | grep v1.36
v1.36.1---vmware.4-vkr.5 v1.36.1+vmware.4-vkr.5 True True 34h
v1.36.2---vmware.2-vkr.3 v1.36.2+vmware.2-vkr.3 True True 34h
v1.36.4---vmware.2-vkr.3 v1.36.4+vmware.2-vkr.3 True True 12h
The namespace’s storage policy is there too, as the StorageClass vsan-default-storage-policy, and the cluster classes run up to builtin-generic-v3.7.0.
The cluster itself is one manifest, applied to that namespace. It names a cluster class, a Kubernetes version and an Ubuntu node image, and sizes the nodes from the namespace’s VM classes:
The Cluster manifest (39 lines of YAML)
apiVersion: cluster.x-k8s.io/v1beta2
kind: Cluster
metadata:
name: planespotter-01
namespace: planespotter-dev
spec:
clusterNetwork:
pods:
cidrBlocks: ["192.168.128.0/18"] # clear of the namespace network, 192.168.0.0/17
services:
cidrBlocks: ["10.112.0.0/16"]
serviceDomain: cluster.local
topology:
classRef:
name: builtin-generic-v3.7.0
namespace: vmware-system-vks-public
version: v1.36 # the newest v1.36 release
controlPlane:
replicas: 1
metadata:
annotations:
run.tanzu.vmware.com/resolve-os-image: os-name=ubuntu,os-version=24.04
workers:
machineDeployments:
- class: node-pool
name: node-pool-1
replicas: 2
metadata:
annotations:
run.tanzu.vmware.com/resolve-os-image: os-name=ubuntu,os-version=24.04
variables:
overrides:
- name: vmClass
value: best-effort-medium # the builds run here
variables:
- name: vmClass
value: best-effort-small
- name: storageClass
value: vsan-default-storage-policy
Under twenty minutes later I had a cluster of my own, with the parts that usually take a ticket already in place:
- Load balancers. A Service of
type: LoadBalancergets an address from NSX, the network behind VKS, with no ingress controller to install. Planespotter’s frontend had one within seconds. - Persistent storage. A
PersistentVolumeClaimbinds to the namespace’s vSAN storage policy, and Planespotter’s Postgres keeps its data on one. - Pod Security. Clusters enforce the Kubernetes
restrictedPod Security Standard by default. Buildpack images already run as a non-root user, one of its requirements, and I never wroteuseradd.
$ kubectl -n planespotter-buildpacks get service frontend
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
frontend LoadBalancer 10.112.138.203 10.0.70.9 80:30832/TCP,8080:30570/TCP 11h
$ kubectl -n planespotter-buildpacks get pvc postgres-pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
postgres-pvc Bound pvc-82145228-1a25-4680-91f7-bdd2511b2b4f 5Gi RWO vsan-default-storage-policy <unset> 11h
What the Platform Patches
Everything I didn’t write for the app belongs to the platform, in the same split VKS already makes for infrastructure:
| Platform | Developer | |
|---|---|---|
| Clusters | The vSphere Namespace: VM classes, storage policy, Kubernetes releases | A Cluster manifest |
| Images | The builder and the package mirror | A Procfile, a project.toml, an Image and an ImagePolicy per service |
The vSphere Namespace gave me Kubernetes without owning a hypervisor. The builder gives me images without owning Ubuntu.
Both rows patch the same way. The platform publishes something newer, a version changes, and the new version rolls out over the old instead of anything being patched in place.
Images. The builder is a store of Paketo buildpacks, a stack naming the Ubuntu build and run images, and the order kpack tries the buildpacks in. Each of patch day’s fixes was a version change in it. Flux applies the platform’s repository to the cluster as well as the app’s, so the change reached kpack as a commit, like everything else.
The builder: a store of buildpacks, a stack and their order (74 lines of YAML)
apiVersion: kpack.io/v1alpha2
kind: ClusterStore
metadata:
name: paketo
spec:
sources:
- image: docker.io/paketobuildpacks/ca-certificates:3.14.3
- image: docker.io/paketobuildpacks/node-engine:8.11.0
- image: docker.io/paketobuildpacks/npm-install:2.3.33
- image: docker.io/paketobuildpacks/node-run-script:2.3.58
- image: docker.io/paketobuildpacks/cpython:1.18.46
- image: docker.io/paketobuildpacks/python-package-managers-install:0.29.0
- image: docker.io/paketobuildpacks/python-package-managers-run:0.6.10
- image: docker.io/paketobuildpacks/python-start:0.18.4
- image: docker.io/paketobuildpacks/procfile:5.15.5
- image: docker.io/paketobuildpacks/environment-variables:4.12.0
- image: docker.io/paketobuildpacks/image-labels:4.14.4
---
apiVersion: kpack.io/v1alpha2
kind: ClusterStack
metadata:
name: noble
spec:
id: io.buildpacks.stacks.noble
buildImage:
image: docker.io/paketobuildpacks/ubuntu-noble-build:0.0.130
runImage:
image: docker.io/paketobuildpacks/ubuntu-noble-run:0.0.130
---
apiVersion: kpack.io/v1alpha2
kind: ClusterBuilder
metadata:
name: python-node
spec:
tag: ghcr.io/darrylcauldwell/kpack-builders/python-node
serviceAccountRef:
name: builder
namespace: kpack
stack:
kind: ClusterStack
name: noble
store:
kind: ClusterStore
name: paketo
order:
# Node builds the CSS, then Python runs the app.
- group:
- id: paketo-buildpacks/ca-certificates
optional: true
- id: paketo-buildpacks/node-engine
- id: paketo-buildpacks/npm-install
- id: paketo-buildpacks/node-run-script
- id: paketo-buildpacks/cpython
- id: paketo-buildpacks/python-package-managers-install
- id: paketo-buildpacks/python-package-managers-run
- id: paketo-buildpacks/python-start
- id: paketo-buildpacks/procfile
- id: paketo-buildpacks/environment-variables
optional: true
- id: paketo-buildpacks/image-labels
optional: true
# Python only.
- group:
- id: paketo-buildpacks/ca-certificates
optional: true
- id: paketo-buildpacks/cpython
- id: paketo-buildpacks/python-package-managers-install
- id: paketo-buildpacks/python-package-managers-run
- id: paketo-buildpacks/python-start
- id: paketo-buildpacks/procfile
- id: paketo-buildpacks/environment-variables
optional: true
- id: paketo-buildpacks/image-labels
optional: true
Pin each image by digest as well as tag. The CPython line is the one the Python patch replaced, and the stack’s two Ubuntu lines are the operating system’s.
Clusters. VKS ships Kubernetes as VMware Kubernetes releases, VKrs, each a Kubernetes version with node OS images built for it, and a cluster names its release in one field, spec.topology.version. Changing that field rolls the cluster onto the new release, and so does moving it to a newer ClusterClass after VKS itself is upgraded: VKS replaces the VMs one by one, “to maintain uptime while preserving persistent data”. It’s the rebase idea one layer down.
Underneath. VCF works the same way down to the hypervisor: rolling updates for the Supervisor, Quick Patch for vCenter, and ESX Live Patch, which can apply a host fix in memory with no maintenance window, no evacuation and no reboot. VCF’s own patching stops at the cluster. Buildpacks carry it the last step, to the app.
The Deal
To move Planespotter I wrote a Cluster manifest and, for each service, a Procfile, a project.toml, an Image and an ImagePolicy. The hypervisor, the network, the storage, Ubuntu, the Python build and the patching belong to the platform. When a fix ships, one platform commit reaches every running app, and a commit in my repository tells me it happened.
Three of Planespotter’s four Dockerfiles can go. The fourth builds Postgres. And the next time something like PHP2Shell lands, patching Planespotter is one platform commit, not my weekend.