Skip to main content
Kubernetes

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-serverCPython layerUbuntu packages
As first built11 CVEs: 1 critical, 2 high76 CVEs, 34 with a fix, 1 high
After the operating system patchunchanged42 CVEs, none with a fix
After the Python patch3 CVEs, medium and lowunchanged
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:

DockerfileBuildpacks
Base imagepython:3.13-slim, named in each Dockerfilethe platform’s run image
Pythonwhichever 3.13 that image carries when CI runsthe platform’s CPython buildpack, at the version project.toml asks for
Node for the CSSa node:22-slim build stagethe Node buildpacks, which leave no Node in the image
Dependenciespip install in each Dockerfilethe package buildpacks, from a pinned requirements.txt
Useruseradd and USERnon-root without asking
Start commandCMDProcfile
BuildCI on every push, several minutes each timekpack in the cluster, on a commit or a patch
Patchevery team rebuilds its own imagesone platform commit
SBOMnonein 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: LoadBalancer gets an address from NSX, the network behind VKS, with no ingress controller to install. Planespotter’s frontend had one within seconds.
  • Persistent storage. A PersistentVolumeClaim binds to the namespace’s vSAN storage policy, and Planespotter’s Postgres keeps its data on one.
  • Pod Security. Clusters enforce the Kubernetes restricted Pod Security Standard by default. Buildpack images already run as a non-root user, one of its requirements, and I never wrote useradd.
$ 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:

PlatformDeveloper
ClustersThe vSphere Namespace: VM classes, storage policy, Kubernetes releasesA Cluster manifest
ImagesThe builder and the package mirrorA 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.