Opens in a new tab
Blog

No More Dockerfiles: Building Reproducible Images with Cloud Native Buildpacks

By
Share on:
Hero image for the anynines blog post "No More Dockerfiles: Building Reproducible Images with Cloud Native Buildpacks," with the anynines logo.

One build process for any language from your laptop to Kubernetes and Cloud Foundry.

What if you never had to write another Dockerfile and still got smaller, more secure, reproducible container images?

That’s what Cloud Native Buildpacks (CNB) do: you hand over your source code, and a proven, shared build process produces a production-ready image. No base images to pick, no layers to optimize, no packaging logic to copy between projects.

This post explains what CNB is and why teams adopt it, then shows it in action across four environments: the pack CLI on your laptop, a GitHub Actions pipeline, kpack on Kubernetes, and Cloud Foundry. By the end, you’ll know how to build your first image from source code without a Dockerfile and how to choose the right workflow for your team.

Why do we write Dockerfiles in the first place?

For most teams, building containerized applications starts with a Dockerfile. It has become the default way to describe how source code turns into a runnable container image. A Dockerfile is explicit, portable, and universally understood. Almost every CI system, registry, and orchestration platform can work with the image it produces.

At its core, a Dockerfile answers a series of practical questions: which base image to use as the underlying OS layer, which system packages and language runtime to install, how to pull in dependencies, how to compile and bundle the application, and which command to run when the container starts. A typical example looks familiar to almost anyone who has built an OCI image:

FROM node:26-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]

Just several lines, and you have a reproducible-enough recipe that any engineer can read and any build server can execute. This simplicity is exactly why the format won.

What Dockerfiles do well

  • Explicit and readable. The build steps are written out in order. There is no hidden magic, and you can always see what goes into the image.
  • Universally supported. Docker, Podman, BuildKit, Kaniko, and virtually every CI/CD platform can build from a Dockerfile. The resulting OCI image runs anywhere containers run.
  • Full control. You decide every layer, every package version, and every optimization. When an application has unusual system dependencies or a bespoke build process, that control is invaluable.
  • Low barrier to start. Getting a single service into a container takes only a few lines, and the model is easy to understand.

Where Dockerfiles start to hurt

The trouble rarely appears with the first Dockerfile. It appears with an increasing number of applications. What looks like a simple solution can lead to rising costs when multiplied across many services and teams.

  • Duplication at scale. Every repository ends up with its own nearly the same Dockerfile. Another base image, a security fix, or a tiny improvement has to be copied by hand into dozens or hundreds of projects.
  • Best practices are hard to enforce. Correct layer ordering, minimal base images, non-root users, and reproducible dependency installs are easy to get wrong. Each Dockerfile is an opportunity to reintroduce a mistake that was already solved elsewhere.
  • It is a different skill from writing the app. Even excellent developers often struggle to write a truly proper Dockerfile, and that is not a failing on their part. Writing the application is about business logic and design; writing a Dockerfile is about packaging. The base image selection, layer caching, build vs. runtime dependencies, image hardening, and attack surface reduction. These are specialized operational concerns, and expecting every developer to master them alongside their actual product work is unrealistic.
  • Inconsistent results across teams. Two teams building the same kind of service often produce very different images, with different sizes, users, and levels of security. That inconsistency makes platform-wide assurances difficult.
  • It mixes two concerns. A Dockerfile blends what the application is with how the platform should package it. Those are different responsibilities, and binding them together means every application team also has to be an image-maintenance team.

None of this makes the Dockerfile a bad tool. For a single service, or for a build that genuinely needs fine-grained control, it remains an excellent choice. The real question is different: does every application team in an organization need to own and maintain the same container-building logic by hand?

That question is where Cloud Native Buildpacks enter the picture.

What are Cloud Native Buildpacks?

Cloud Native Buildpacks (CNB) are an open specification, hosted by the Cloud Native Computing Foundation, for transforming application source code into OCI-compliant container images without a Dockerfile. Instead of writing out build steps by hand, you point CNB tooling at your source code, and it figures out how to build and package the application for you.

Buildpacks were originally created at Heroku and later adopted by Cloud Foundry to let developers push source code and get a running application in return. Cloud Native Buildpacks took that proven model, standardized it as an open specification, and made the output a portable OCI image that runs on any container platform.

The shift in responsibility is the main point. With a Dockerfile, you describe how to build the image. With buildpacks, the buildpacks know how to build applications of a given type, and you simply provide the source code. The knowledge of best practices, correct base images, optimal layer structure, and security patches resides in the buildpacks and can be maintained centrally rather than copied into every repository.

Two concepts you actually need

You can get very far with just two terms:

  • Buildpacks are the reusable tooling for building a particular kind of app. There are buildpacks for Node.js, Java, Go, Python, and more. You don’t install or configure them one by one. They come bundled together for you.
  • A builder is that bundle: a ready-to-use image that packages a whole set of buildpacks plus the base images your app builds and runs on. In practice, picking a builder is usually the only choice a developer has to make.

And it’s not just traditional programming languages. There are buildpacks for static websites, turning a folder of HTML, CSS, and JavaScript into an image served by a web server, and even for pre-compiled binaries, where you already have a built executable and just need it packaged into a proper OCI image. Whatever shape your application takes, there’s usually a buildpack that fits.

The nice part is that you rarely think about individual buildpacks at all. You choose a builder, point it at your code, and it works out the rest.

How a build flows

Every CNB build follows the same simple path, no matter the language:

   Your source code
         |
         v
   Builder  →  detects your language, builds and packages the app
         |
         v
   A ready-to-run OCI image

CNB looks at your project, recognizes what it is (a package.json means Node.js, a pom.xml means Java, and so on), builds it with the right tools, and packages the result as a standard OCI image. You don’t declare your stack. It’s detected from the code itself.

One practical bonus: rebuilds are fast. CNB stores your dependencies separately from your application code, so when you change only your code, it reuses the cached dependencies and rebuilds just the part that changed. That keeps building quickly, and images are small.

One specification, many implementations

An important thing to understand right away: CNB is a specification and lifecycle, not a single tool. Several different tools implement it, each suited to a different environment:

  • pack — a CLI for building images locally on a developer’s machine.
  • GitHub Actions — a CI pipeline that can build images automatically. It can trigger on events such as a push or a pull request, run the CNB build as a step, and publish the resulting image to a registry — no manual command needed.
  • kpack — a Kubernetes-native controller that automates builds inside a cluster.
  • Cloud Foundry — a platform that uses buildpacks to stage and run applications directly.

They share the same underlying model, so an application that builds with one can generally build with the others. The rest of this article walks through exactly these scenarios, starting locally with pack, moving into a GitHub Actions pipeline, automating builds in Kubernetes with kpack, and finally seeing how Cloud Foundry uses the same buildpack approach without ever producing a Docker image at all.

But first, it is worth being concrete about why a team would adopt this model.

Why use Cloud Native Buildpacks?

The appeal of CNB comes down to a few concrete benefits.

The same build everywhere

Because CNB is a shared specification, the same source code produces the same kind of image whether it’s built on your laptop, in a CI pipeline, or in a Kubernetes cluster. A developer can write an application once and have it built and deployed across different platforms without changing anything about the app itself.

This consistency removes a whole category of “works on my machine” problems. The build no longer depends on how each person happened to write their Dockerfile or which tools they had installed. Choose a builder, and every environment that uses it produces comparable, repeatable results.

In fact, it can go further than “comparable.” The CNB lifecycle is designed for reproducible builds. It normalizes details like file timestamps so that identical inputs produce an identical image, down to the same digest. The catch is that all inputs must be identical, and there are two of them: the builder and your dependencies. Pinning the builder to a specific version fixes the buildpacks and base images, but your application’s dependencies are a separate input. If they float a latest tag or an unpinned range that resolves to whatever is newest that day, the resulting layers can differ from build to build.

This is exactly what lockfiles are for. When you commit a lockfile (package-lock.json, yarn.lock, go.sum, poetry.lock, Gemfile.lock, and so on), your dependencies are pinned to exact versions. Pin the builder and commit a lockfile, and the same source really does produce the same image no matter where or when it’s built, which makes builds auditable and easy to re-create later.

Less to maintain, for everyone

With buildpacks, the knowledge of how to build and package an app lives in a single shared place rather than being copied into every repository. That changes who does what:

  • Developers focus on their application and its dependencies. They don’t have to become experts in base images, layer caching, or image hardening.
  • Platform teams maintain the builders centrally. When a base image needs a security patch or a runtime needs an upgrade, they update the builder once, and every application picks up the change on its next build.

When a critical vulnerability appears in a base image, this is the difference between updating one builder and manually editing hundreds of Dockerfiles. The applications simply rebuild on a patched base image.

Flexible configuration through environment variables

Buildpacks aim to work sensibly with zero configuration, but real projects sometimes need to adjust the build. CNB handles this through environment variables, so you can tune the build without touching the application code or writing any build script.

Need a specific runtime version, or want to pass a flag to the build tool? You set an environment variable, and the buildpack picks it up. For example, telling the build which language version to use is often as simple as:

pack build my-app --builder <builder-name> --env BP_JVM_VERSION=26

The same mechanism works across environments. The exact variable is set differently in a CI pipeline, in a kpack resource, or on a Cloud Foundry app, but the idea is identical. This gives teams fine-grained control over the build while keeping the application code clean.

A quick but important note: environment variables are great for configuration, but not for secrets. Sensitive values like registry passwords or API keys should go through the secret-management features of whatever platform you’re using, so they never end up baked into a built image or printed in a build log.

Fast, efficient rebuilds

As mentioned earlier, CNB separates dependencies from application code and reuses cached layers when only part of the app changes. In practice, this means the common case, where you changed some code and want to rebuild, is fast, and the resulting images stay small because shared layers aren’t duplicated.

With the what and the why covered, it’s time to get practical. The next sections walk through CNB in action across four environments, starting with the quickest way to try it: the pack CLI on your own machine.

Scenario 1: Building locally with the pack CLI

The fastest way to experience Cloud Native Buildpacks is pack, a command-line tool from the CNB project. It takes your source code and turns it into a runnable OCI image with a single command, no Dockerfile in sight.

What you need

pack builds images using a container runtime, so you’ll need Docker (or another compatible runtime) installed and running. Beyond that, the only thing to install is pack itself.

You can install it either using Homebrew:

brew install buildpacks/tap/pack

Or you can download the binary from the Buildpacks releases page or use your platform’s package manager. Once it’s installed, confirm it’s working:

pack --version

Building your first image

Imagine a simple web app: a Node.js service with package.json and server.js. To build it, go to the project directory and run:

pack build my-app --builder paketobuildpacks/builder-jammy-base

That’s the whole command. Here’s what each part means:

  • my-app is the name (and optional tag) for the image you’re creating.
  • --builder tells pack which builder to use. Here we use one of the Paketo Buildpacks builders, a popular open-source set of buildpacks that covers many languages. The jammy in the name means it’s based on Ubuntu 22.04 “Jammy Jellyfish” — that’s the operating system your app will build and run on.

On the first run, pack downloads the builder image, then detects that it is a Node.js project, installs the appropriate runtime, pulls in the dependencies, and packages everything into an image. The output walks you through each phase as it happens.

If you set a default builder once, you can even drop the flag:

pack config default-builder paketobuildpacks/builder-jammy-base
pack build my-app

Running the result

What comes out is an ordinary OCI image. You run it exactly like any other container:

docker run --rm -p 8080:8080 my-app

Open http://localhost:8080, and your app is live, built without writing a single line of packaging instructions.

pack build my-app \
  --builder paketobuildpacks/builder-jammy-base \
  --env BP_NODE_VERSION=26

The buildpack reads BP_NODE_VERSION and provisions that version of Node.js. No change to your application code required. Different buildpacks expose different variables (for example, BP_JVM_VERSION for Java), but the mechanism is always the same.

Why start here

Building locally with pack is the ideal way to start using CNB, rather than a Dockerfile. There’s nothing to configure, the feedback is immediate, and the image you produce is the same kind of artifact you’d get from any other CNB environment. Once it works locally, you can easily move the same build to an automated pipeline. The next section explains how.

Scenario 2: Automating builds in GitHub Actions

Building on your laptop is great for trying things out, but real projects need images built automatically, on every change, in a place everyone trusts. That’s what CI pipelines are for, and since GitHub Actions is where many teams already live, it’s a natural next step.

​The key point is that nothing really changes. It’s the same pack build you just ran locally, only now it runs on GitHub’s servers whenever you push code, and the finished image is pushed to a container registry instead of staying on your machine.

The pieces of the pipeline

A CI build needs to do a few things in order:

  1. Trigger on the right event — typically a push to your main branch (or a devel branch).
  2. Check out your source code.
  3. Log in to the container registry where the image will be stored.
  4. Build and publish the image with CNB.

The CNB project publishes an official setup-pack action that installs the pack CLI into the runner, so you can use the exact same tool you use locally. From there, it’s just pack build with a --publish flag.

A complete workflow

Here’s a workflow that builds an image on every push to main and publishes it to the GitHub Container Registry ghcr.io:

name: Build image

​on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Check out source
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Install pack
        uses: buildpacks/github-actions/setup-pack@v6.1.0

      - name: Build and publish image
        run: |
          pack build ghcr.io/${{ github.repository }}:${{ github.sha }} \
            --builder paketobuildpacks/builder-jammy-base \
            --publish

A few details:

  • The trigger is the on: block. Here, it’s a push to main, but you could also build on pull requests, tags, or a schedule.
  • Authentication uses the built-in GITHUB_TOKEN secret, so there’s nothing extra to configure to push to ghcr.io. For another registry (Docker Hub, a private registry), you’d store its credentials as repository secrets and reference them here.
  • The image tag uses ${{ github.sha }} the commit hash so every build produces a uniquely identifiable, traceable image. Many teams add a moving tag such as latest alongside it for convenience.
  • --publish tells pack to push the finished image straight to the registry instead of loading it locally.

The same configuration, in CI

Here’s how a shared specification benefits us in practice: the command that worked on your laptop works unchanged in CI. There’s no separate “CI build” to maintain and keep in sync with local development. It’s the same build, in a different place.

Best practices

Once the core pipeline is in place, a few refinements are worth mentioning:

  • Pin your versions. Pinning both the setup-pack action and the builder makes CI builds reproducible and shields you from surprise changes.
  • Cache between runs. CNB’s layer reuse works in CI too, and caching can noticeably speed up repeat builds.
  • Scan the image. Adding a vulnerability scan after the build is an easy way to catch known issues before the image ships.

With CI covered, the image is now built and published automatically on every change. But some teams want that automation to live inside their runtime platform itself, which brings us to Kubernetes and kpack.

Scenario 3: Automating builds in Kubernetes with kpack

In the GitHub Actions example, a build happened because you pushed code, and a workflow ran. kpack reverses the model. It’s a Kubernetes-native platform that treats container images as something the cluster continuously maintains, not something you build once and forget.

​Instead of running pack yourself or by CI, you describe the image you want as a Kubernetes resource, and kpack makes it real — and keeps it that way. It constantly monitors your source code, buildpacks, and base images underneath. When any of them change, it rebuilds the application automatically. New commit? Rebuild. Does the base image get a security patch? Every affected image rebuilds, no human involved.

A different way of thinking about builds

This is the key shift: with kpack, an image is declarative. You state the desired result as “an image built from this repository, using this builder,” and the controller’s job is to keep reality matching that declaration. It’s the same philosophy Kubernetes applies to running containers, now applied to building them.

​That makes kpack less of a “CI tool” and more of an always-on image factory living inside your cluster. It’s especially powerful for the security topic from earlier: when a base image is patched, kpack can automatically rebuild hundreds of application images, keeping all your deployments current without anyone editing a pipeline.

The building blocks

kpack introduces a handful of Kubernetes resources, usually split between two audiences.

A platform team sets up the shared platform, typically once:

  • A ClusterStore collects the buildpacks available to the cluster.
  • A ClusterStack defines the build and run base images.
  • A ClusterBuilder combines those into a ready-to-use builder. The same concept as the builder you chose with pack, now a cluster resource.

Developers then only deal with one resource:

  • An Image describes what to build: where the source lives, which builder to use, and where to push the result.

This division reflects a familiar split: platform teams own the build platform, and developers just point at their code.

Defining an image

Once the platform pieces exist, a developer’s entire interface can be a single manifest:

apiVersion: kpack.io/v1alpha2
kind: Image
metadata:
  name: my-app
  namespace: my-namespace
spec:
  tag: ghcr.io/my-org/my-app
  builder:
    name: default-builder
    kind: ClusterBuilder
  source:
    git:
      url: https://github.com/my-org/my-app.git
      revision: main

Apply it like any Kubernetes resource:

kubectl apply -f image.yaml

From this point on, kpack takes over. It clones the repository, runs the CNB build with the referenced builder, and pushes the finished image to ghcr.io/my-org/my-app. Every new commit to main triggers a fresh build, and each successful build updates the Image resource with the exact, digest-pinned reference of what it produced.

​How that freshly built image is actually deployed is out of scope for this article kpack builds images. It doesn’t deploy them. But as an example, a CD/GitOps tool like Flux or Argo CD can easily watch for newly built images and automatically update your Kubernetes deployments to the new version, closing the loop from commit to running app.

​You can watch it work with familiar tooling:

kubectl -n my-team get images
kubectl -n my-team get builds

When kpack is the right fit

kpack perfectly fits when image building should be an automated, self-healing part of your platform rather than a step someone triggers. If you already run Kubernetes, want images that rebuild themselves when their source changes, and want a clean split between platform and application concerns, it’s a great fit. For a small project or a single service, the earlier pack or GitHub Actions approaches are usually simpler.

​So far, every scenario has ended with the same kind of artifact: an OCI image pushed to a registry. The final stop is different. Cloud Foundry uses the very same buildpacks, but never hands you a Docker image at all.

Scenario 4: Buildpacks under the hood in Cloud Foundry

Cloud Foundry is a platform-as-a-service where the developer experience is famously simple: you run cf push, and moments later your application is running on a URL. No Dockerfile, no image to build, no registry to configure. Buildpacks are the machinery that makes this possible. In fact, Cloud Foundry is where buildpacks were used, years before they were standardized as Cloud Native Buildpacks.

​What makes this scenario worth a section of its own is an important point: Cloud Foundry uses buildpacks, but it does not produce a Docker image. That difference is the whole reason it’s interesting.

The developer experience

From a developer’s perspective, the entire workflow is one command:

cf push my-app

You point Cloud Foundry at your source code, and it does the rest. And “the rest” is a lot: it uploads your source, stages it with buildpacks just like pack, detecting the programming language, installing the runtime, and resolving dependencies packages the result, starts the application in a container, wires up networking and routing, and exposes it on a public URL, often with load balancing and health checks already in place. Configuration works the same way you’ve seen above through environment variables:

cf set-env my-app BP_NODE_VERSION 26
cf restage my-app

The developer never thinks about base images, layers, or packaging. They push code and get an app running. This is exactly the “focus on the application, not the plumbing” benefit from earlier, taken to its logical conclusion.

CNB buildpacks vs. Cloud Foundry’s native buildpacks

One clarification worth making: Cloud Foundry has its own native buildpacks, which predate CNB. But CNB buildpacks can also be used with Cloud Foundry, and that’s the interesting part. It means you can build the same source code with the same buildpack and get the same compiled application, whether you target a local OCI image or a Cloud Foundry app.

The output: the same artifacts, minus the stack

The difference from the other scenarios is small and worth only a mention. Cloud Foundry doesn’t produce an OCI image; it produces a droplet. In practice, the droplet contains all the same artifacts CNB creates for your compiled app, dependencies, and runtime layers, except the base stack layer.

​That exception is the whole point. CNB normally bundles a run image (the OS base) into the final OCI image. Cloud Foundry instead supplies its own pre-configured stack at runtime and combines it with the droplet inside a standard Cloud Foundry container. So the only real difference is where the base filesystem comes from, CF’s own stack rather than the one provided by a CNB builder. The build (compiled) application artifacts are identical.

​Which means reproducibility carries over here as well: the same source and the same buildpack produce the same application, whether it ends up in an OCI image in another environment or in a droplet on Cloud Foundry.

Which workflow should you use?

Pick the tool that fits where your code already lives: pack for local development and debugging, GitHub Actions when your CI/CD is on GitHub, kpack when you run Kubernetes and want self-healing image builds, Cloud Foundry when your application runs on Cloud Foundry.

​And in addition to the four cases above, there are others. Because CNB is an open specification, plenty of other tools can build images with the very same buildpacks, for example:

  • GitLab CI/CD: GitLab’s Auto DevOps uses Cloud Native Buildpacks to build images without a Dockerfile.
  • TektonCD, CircleCI, Jenkins, and other CI systems: anywhere you can install the pack CLI, you can run a CNB build.
  • Heroku: the original home of buildpacks, which continues to build applications this way.

The takeaway is the same throughout: whichever tool fits where your code already lives, the buildpack foundation underneath stays consistent.

Conclusion

Cloud Native Buildpacks turn source code into runnable artifacts without asking every team to write and maintain a Dockerfile. The build logic lives in shared, reusable buildpacks, so developers focus on their application while platform teams keep runtimes and base images up to date in one place.

​The real strength is consistency. The same source and the same buildpack produce the same result everywhere — on your laptop with pack, in a GitHub Actions pipeline, in a Kubernetes cluster with kpack, and even on Cloud Foundry, where the compiled artifacts are identical, and only the stack differs.

​If you’re curious, the best next step is the smallest one: install pack, point it at an existing project, and see what comes out. From there, the same build works wherever you need it.

Resources & further reading

Topics:
Share on: