I love the power of containers, but I’ve never loved Dockerfile. In this post we’ll build a working OCI image of a Ruby on Rails application that can run locally without the need to write or maintain a Dockerfile. You will learn about the Cloud Native Buildpack (CNB) ecosystem, and how to utilize the pack CLI to build images. Let’s get to it!

This post is extracted from a tutorial I wrote for Heroku Cloud Native Buildpacks. Future revisions will be updated on the GitHub repo.

Install the pack CLI

We assume you have docker installed and a working copy of git. Next, you will need to install the CLI tool for building CNBs, pack CLI. If you’re on a Mac you can install it via Homebrew:

$ brew install buildpacks/tap/pack

Ensure that pack is installed correctly:

$ pack --version
0.33.2+git-f2cffc4.build-5562

Configure the default pack builder

Once pack is installed, the only configuration you’ll need for this tutorial is to set a default builder:

$ pack config default-builder heroku/builder:22
Builder 'heroku/builder:22' is now the default builder

You can view your default builder at any time:

$ pack config default-builder
The current default builder is 'heroku/builder:22'

The following tutorial is built on amd64 architecture (also known as x86). If you are building on a machine with different architecture (such as arm64/aarch64 for a Mac) you will need to tell Docker to use linux/amd64 architecture. You can do this via a --platform linux/amd64 flag or by exporting an environment variable:

$ export DOCKER_DEFAULT_PLATFORM=linux/amd64

What is a builder?

Skip ahead if you want to build the application first and get into the details later. You won’t need to know about builders for the rest of this tutorial.

In short, a builder is a delivery mechanism for buildpacks. A builder contains references to base images and individual buildpacks. A base image contains the operating system and system dependencies. Buildpacks are the components that will configure an image to run your application, that’s where the bulk of the logic lives and why the project is called “Cloud Native Buildpacks” and not “Cloud Native Builders.”

You can view the contents of a builder via the command pack builder inspect. For example:

$ pack builder inspect heroku/builder:22 | grep Buildpacks: -m1 -A10
Buildpacks:
  ID                                NAME                               VERSION        HOMEPAGE
  heroku/go                         Heroku Go                          0.2.1          https://github.com/heroku/buildpacks-go
  heroku/gradle                     Heroku Gradle                      4.1.0          https://github.com/heroku/buildpacks-jvm
  heroku/java                       Heroku Java                        4.1.0          https://github.com/heroku/buildpacks-jvm
  heroku/jvm                        Heroku OpenJDK                     4.1.0          https://github.com/heroku/buildpacks-jvm
  heroku/maven                      Heroku Maven                       4.1.0          https://github.com/heroku/buildpacks-jvm
  heroku/nodejs                     Heroku Node.js                     3.0.5          https://github.com/heroku/buildpacks-nodejs
  heroku/nodejs-corepack            Heroku Node.js Corepack            3.0.5          https://github.com/heroku/buildpacks-nodejs
  heroku/nodejs-engine              Heroku Node.js Engine              3.0.5          https://github.com/heroku/buildpacks-nodejs
  heroku/nodejs-npm-engine          Heroku Node.js npm Engine          3.0.5          https://github.com/heroku/buildpacks-nodejs

This output shows the various buildpacks that represent the different languages that are supported by this builder such as heroku/go and heroku/nodejs-engine.

Download an example Ruby on Rails application

How do you configure a CNB? Give them an application. While Dockerfile is procedural, buildpacks, are declarative. A buildpack will determine what your application needs to function by inspecting the code on disk.

For this example, we’re using a pre-built Ruby on Rails application. Download it now:

$ git clone https://github.com/heroku/ruby-getting-started
$ cd ruby-getting-started

Verify you’re in the correct directory:

$ ls
Gemfile
Gemfile.lock
Procfile
README.md
Rakefile
app
app.json
bin
config
config.ru
db
lib
log
package.json
public
test
vendor

This tutorial was built using the following commit SHA:

$ git log --oneline | head -n1
894fc54 Upgrade Ruby (#144)

Build the application image with the pack CLI

Now build an image named my-image-name by executing the heroku builder against the application by running the pack build command:

$ pack build my-image-name --path .
22: Pulling from heroku/builder
Digest: sha256:40310993837644b85c3704acd1dede0533a9210cbefa428c68b0fc550be55e63
Status: Image is up to date for heroku/builder:22
22-cnb: Pulling from heroku/heroku
Digest: sha256:b1dedd0404b81a545d55739bf86ff95757d3a0544d272ada9f67e0eaf2b10b11
Status: Image is up to date for heroku/heroku:22-cnb
===> ANALYZING
Image with name "my-image-name" not found
===> DETECTING
3 of 5 buildpacks participating
heroku/nodejs-engine 3.0.5
heroku/ruby          2.1.3
heroku/procfile      3.0.1
===> RESTORING
===> BUILDING

[Heroku Node.js Engine Buildpack]

[Checking Node.js version]
Node.js version not specified, using 20.x
Resolved Node.js version: 20.12.2

[Installing Node.js distribution]
Downloading Node.js 20.12.2
Extracting Node.js 20.12.2
Installing Node.js 20.12.2

# Heroku Ruby Buildpack

- Metrics agent
  - Skipping install (`barnes` gem not found)
- Ruby version `3.2.4` from `Gemfile.lock`
  - Installing ...... (3.151s)
- Bundler version `2.5.9` from `Gemfile.lock`
  - Running `gem install bundler --version 2.5.9` ... (0.909s)
- Bundle install
  - Running `BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`

      Fetching gem metadata from https://rubygems.org/.........
      Fetching rake 13.1.0
      Installing rake 13.1.0
      Fetching base64 0.2.0
      Fetching bigdecimal 3.1.6
      Fetching concurrent-ruby 1.2.3
      Fetching minitest 5.21.2
      Fetching mutex_m 0.2.0
      Fetching builder 3.2.4
      Fetching crass 1.0.6
      Fetching connection_pool 2.4.1
      Fetching erubi 1.12.0
      Fetching racc 1.7.3
      Fetching rack 3.0.9
      Fetching nio4r 2.7.0
      Installing base64 0.2.0
      Fetching websocket-extensions 0.1.5
      Installing bigdecimal 3.1.6 with native extensions
      Installing concurrent-ruby 1.2.3
      Installing minitest 5.21.2
      Fetching zeitwerk 2.6.12
      Fetching timeout 0.4.1
      Installing mutex_m 0.2.0
      Fetching marcel 1.0.2
      Installing builder 3.2.4
      Fetching mini_mime 1.1.5
      Installing crass 1.0.6
      Fetching date 3.3.4
      Installing connection_pool 2.4.1
      Fetching msgpack 1.7.2
      Installing erubi 1.12.0
      Fetching coffee-script-source 1.12.2
      Installing rack 3.0.9
      Fetching execjs 2.8.1
      Installing racc 1.7.3 with native extensions
      Installing nio4r 2.7.0 with native extensions
      Installing websocket-extensions 0.1.5
      Fetching stringio 3.1.0
      Installing zeitwerk 2.6.12
      Fetching io-console 0.7.2
      Installing timeout 0.4.1
      Fetching webrick 1.8.1
      Installing marcel 1.0.2
      Fetching thor 1.3.0
      Installing mini_mime 1.1.5
      Fetching ffi 1.15.5
      Installing date 3.3.4 with native extensions
      Installing msgpack 1.7.2 with native extensions
      Installing coffee-script-source 1.12.2
      Fetching rb-fsevent 0.11.2
      Installing execjs 2.8.1
      Fetching pg 1.5.4
      Installing stringio 3.1.0 with native extensions
      Installing io-console 0.7.2 with native extensions
      Installing webrick 1.8.1
      Fetching tilt 2.1.0
      Installing thor 1.3.0
      Fetching turbolinks-source 5.2.0
      Installing ffi 1.15.5 with native extensions
      Installing rb-fsevent 0.11.2
      Fetching drb 2.2.0
      Installing tilt 2.1.0
      Installing pg 1.5.4 with native extensions
      Fetching i18n 1.14.1
      Installing turbolinks-source 5.2.0
      Installing drb 2.2.0
      Fetching tzinfo 2.0.6
      Fetching rack-session 2.0.0
      Installing i18n 1.14.1
      Installing rack-session 2.0.0
      Fetching rack-test 2.1.0
      Fetching sprockets 4.2.0
      Installing tzinfo 2.0.6
      Installing rack-test 2.1.0
      Fetching websocket-driver 0.7.6
      Installing sprockets 4.2.0
      Fetching net-protocol 0.2.2
      Installing websocket-driver 0.7.6 with native extensions
      Installing net-protocol 0.2.2
      Fetching coffee-script 2.4.1
      Fetching uglifier 4.2.0
      Installing coffee-script 2.4.1
      Fetching rackup 2.1.0
      Installing uglifier 4.2.0
      Fetching turbolinks 5.2.1
      Installing rackup 2.1.0
      Fetching net-pop 0.1.2
      Installing turbolinks 5.2.1
      Fetching net-smtp 0.4.0.1
      Installing net-pop 0.1.2
      Installing net-smtp 0.4.0.1
      Fetching nokogiri 1.16.0 (x86_64-linux)
      Installing nokogiri 1.16.0 (x86_64-linux)
      Fetching loofah 2.22.0
      Installing loofah 2.22.0
      Fetching rails-html-sanitizer 1.6.0
      Installing rails-html-sanitizer 1.6.0
      Fetching psych 5.1.2
      Installing psych 5.1.2 with native extensions
      Fetching rdoc 6.6.2
      Fetching reline 0.4.2
      Fetching puma 6.4.2
      Installing rdoc 6.6.2
      Installing reline 0.4.2
      Installing puma 6.4.2 with native extensions
      Fetching irb 1.11.1
      Fetching sdoc 2.6.1
      Installing sdoc 2.6.1
      Installing irb 1.11.1
      Fetching net-imap 0.4.9.1
      Installing net-imap 0.4.9.1
      Fetching mail 2.8.1
      Installing mail 2.8.1
      Fetching bootsnap 1.18.3
      Installing bootsnap 1.18.3 with native extensions
      Fetching activesupport 7.1.3
      Installing activesupport 7.1.3
      Fetching activemodel 7.1.3
      Fetching globalid 1.2.1
      Fetching rails-dom-testing 2.2.0
      Installing activemodel 7.1.3
      Fetching activerecord 7.1.3
      Installing globalid 1.2.1
      Fetching activejob 7.1.3
      Installing rails-dom-testing 2.2.0
      Fetching actionview 7.1.3
      Installing activerecord 7.1.3
      Installing activejob 7.1.3
      Installing actionview 7.1.3
      Fetching actionpack 7.1.3
      Fetching jbuilder 2.11.5
      Installing actionpack 7.1.3
      Installing jbuilder 2.11.5
      Fetching activestorage 7.1.3
      Fetching actionmailer 7.1.3
      Fetching actioncable 7.1.3
      Fetching railties 7.1.3
      Fetching sprockets-rails 3.4.2
      Installing activestorage 7.1.3
      Fetching actiontext 7.1.3
      Fetching actionmailbox 7.1.3
      Installing actionmailer 7.1.3
      Installing actioncable 7.1.3
      Installing railties 7.1.3
      Installing sprockets-rails 3.4.2
      Fetching coffee-rails 5.0.0
      Fetching jquery-rails 4.6.0
      Installing actiontext 7.1.3
      Installing actionmailbox 7.1.3
      Fetching rails 7.1.3
      Installing coffee-rails 5.0.0
      Installing jquery-rails 4.6.0
      Installing rails 7.1.3
      Fetching rb-inotify 0.10.1
      Fetching sassc 2.4.0
      Installing rb-inotify 0.10.1
      Fetching listen 3.8.0
      Installing sassc 2.4.0 with native extensions
      Installing listen 3.8.0
      Fetching sassc-rails 2.1.2
      Installing sassc-rails 2.1.2
      Fetching sass-rails 6.0.0
      Installing sass-rails 6.0.0
      Bundle complete! 13 Gemfile dependencies, 83 gems now installed.
      Gems in the groups 'development' and 'test' were not installed.
      Bundled gems are installed into `/layers/heroku_ruby/gems`

  - Done (1m 41s)
- Setting default processes
  - Running `bundle list` ... (0.278s)
  - Detected rails app (`rails` gem found)
- Rake assets install
  - Detected rake (`rake` gem found, `Rakefile` found at `/workspace/Rakefile`)
  - Running `bundle exec rake -P --trace` .... (1.222s)
  - Compiling assets with cache (detected `rake assets:precompile` and `rake assets:clean` via `bundle exec rake -P`)
  - Creating cache for /workspace/public/assets
  - Creating cache for /workspace/tmp/cache/assets
  - Running `bundle exec rake assets:precompile assets:clean --trace`

      ** Invoke assets:precompile (first_time)
      ** Invoke assets:environment (first_time)
      ** Execute assets:environment
      ** Invoke environment (first_time)
      ** Execute environment
      ** Execute assets:precompile
      I, [2024-04-30T20:39:30.145500 #8745]  INFO -- : Writing /workspace/public/assets/manifest-dad05bf766af0fe3d79dd746db3c1361c0583026cdf35d6a2921bccaea835331.js
      I, [2024-04-30T20:39:30.146240 #8745]  INFO -- : Writing /workspace/public/assets/manifest-dad05bf766af0fe3d79dd746db3c1361c0583026cdf35d6a2921bccaea835331.js.gz
      I, [2024-04-30T20:39:30.146877 #8745]  INFO -- : Writing /workspace/public/assets/lang-logo-b6c7c4b6a37e9c2425ca4d54561010c0719870ae325c849de398499f1ab098a9.png
      I, [2024-04-30T20:39:30.147235 #8745]  INFO -- : Writing /workspace/public/assets/application-9ced36c9568ebfd1053e04ba411af767274dfcccd9807c0989f8bd17ca5e8f5b.js
      I, [2024-04-30T20:39:30.147380 #8745]  INFO -- : Writing /workspace/public/assets/application-9ced36c9568ebfd1053e04ba411af767274dfcccd9807c0989f8bd17ca5e8f5b.js.gz
      I, [2024-04-30T20:39:30.147476 #8745]  INFO -- : Writing /workspace/public/assets/welcome-27cfb9694c5e92d25d972c2b4a2d2e222ad088aef866823f772241c1db423402.js
      I, [2024-04-30T20:39:30.147543 #8745]  INFO -- : Writing /workspace/public/assets/welcome-27cfb9694c5e92d25d972c2b4a2d2e222ad088aef866823f772241c1db423402.js.gz
      I, [2024-04-30T20:39:30.148575 #8745]  INFO -- : Writing /workspace/public/assets/widgets-27cfb9694c5e92d25d972c2b4a2d2e222ad088aef866823f772241c1db423402.js
      I, [2024-04-30T20:39:30.148660 #8745]  INFO -- : Writing /workspace/public/assets/widgets-27cfb9694c5e92d25d972c2b4a2d2e222ad088aef866823f772241c1db423402.js.gz
      I, [2024-04-30T20:39:30.148764 #8745]  INFO -- : Writing /workspace/public/assets/application-776d900b9840362472b5b6b4afb9b798c78d53098a77b289b8bfc22c6d241913.css
      I, [2024-04-30T20:39:30.149044 #8745]  INFO -- : Writing /workspace/public/assets/application-776d900b9840362472b5b6b4afb9b798c78d53098a77b289b8bfc22c6d241913.css.gz
      I, [2024-04-30T20:39:30.149165 #8745]  INFO -- : Writing /workspace/public/assets/scaffolds-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.css
      I, [2024-04-30T20:39:30.149252 #8745]  INFO -- : Writing /workspace/public/assets/scaffolds-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.css.gz
      I, [2024-04-30T20:39:30.149340 #8745]  INFO -- : Writing /workspace/public/assets/theme-776d900b9840362472b5b6b4afb9b798c78d53098a77b289b8bfc22c6d241913.css
      I, [2024-04-30T20:39:30.150855 #8745]  INFO -- : Writing /workspace/public/assets/theme-776d900b9840362472b5b6b4afb9b798c78d53098a77b289b8bfc22c6d241913.css.gz
      I, [2024-04-30T20:39:30.150984 #8745]  INFO -- : Writing /workspace/public/assets/welcome-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.css
      I, [2024-04-30T20:39:30.151068 #8745]  INFO -- : Writing /workspace/public/assets/welcome-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.css.gz
      I, [2024-04-30T20:39:30.151356 #8745]  INFO -- : Writing /workspace/public/assets/widgets-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.css
      I, [2024-04-30T20:39:30.151485 #8745]  INFO -- : Writing /workspace/public/assets/widgets-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.css.gz
      I, [2024-04-30T20:39:30.151591 #8745]  INFO -- : Writing /workspace/public/assets/actiontext-78de0ebeae470799f9ec25fd0e20ae2d931df88c2ff9315918d1054a2fca2596.js
      I, [2024-04-30T20:39:30.151691 #8745]  INFO -- : Writing /workspace/public/assets/actiontext-78de0ebeae470799f9ec25fd0e20ae2d931df88c2ff9315918d1054a2fca2596.js.gz
      I, [2024-04-30T20:39:30.151900 #8745]  INFO -- : Writing /workspace/public/assets/actiontext.esm-328ef022563f73c1b9b45ace742bd21330da0f6bd6c1c96d352d52fc8b8857e5.js
      I, [2024-04-30T20:39:30.152020 #8745]  INFO -- : Writing /workspace/public/assets/actiontext.esm-328ef022563f73c1b9b45ace742bd21330da0f6bd6c1c96d352d52fc8b8857e5.js.gz
      I, [2024-04-30T20:39:30.152811 #8745]  INFO -- : Writing /workspace/public/assets/trix-e17a480fcb4e30c8571f0fed42dc81de5faeef93755ca30fe9623eb3f5c709e5.js
      I, [2024-04-30T20:39:30.152891 #8745]  INFO -- : Writing /workspace/public/assets/trix-e17a480fcb4e30c8571f0fed42dc81de5faeef93755ca30fe9623eb3f5c709e5.js.gz
      I, [2024-04-30T20:39:30.152977 #8745]  INFO -- : Writing /workspace/public/assets/trix-5552afe828fe79c41e53b9cc3616e9d7b8c2de1979ea62cbd663b88426ec41de.css
      I, [2024-04-30T20:39:30.153041 #8745]  INFO -- : Writing /workspace/public/assets/trix-5552afe828fe79c41e53b9cc3616e9d7b8c2de1979ea62cbd663b88426ec41de.css.gz
      I, [2024-04-30T20:39:30.154244 #8745]  INFO -- : Writing /workspace/public/assets/activestorage-503a4fe23aabfbcb752dad255f01835904e6961d5f20d1de13987a691c27d9cd.js
      I, [2024-04-30T20:39:30.154300 #8745]  INFO -- : Writing /workspace/public/assets/activestorage-503a4fe23aabfbcb752dad255f01835904e6961d5f20d1de13987a691c27d9cd.js.gz
      I, [2024-04-30T20:39:30.154371 #8745]  INFO -- : Writing /workspace/public/assets/activestorage.esm-b3f7f0a5ef90530b509c5e681c4b3ef5d5046851e5b70d57fdb45e32b039c883.js
      I, [2024-04-30T20:39:30.154512 #8745]  INFO -- : Writing /workspace/public/assets/activestorage.esm-b3f7f0a5ef90530b509c5e681c4b3ef5d5046851e5b70d57fdb45e32b039c883.js.gz
      I, [2024-04-30T20:39:30.154652 #8745]  INFO -- : Writing /workspace/public/assets/actioncable-1c7f008c6deb7b55c6878be38700ff6bf56b75444a086fa1f46e3b781365a3ea.js
      I, [2024-04-30T20:39:30.154712 #8745]  INFO -- : Writing /workspace/public/assets/actioncable-1c7f008c6deb7b55c6878be38700ff6bf56b75444a086fa1f46e3b781365a3ea.js.gz
      I, [2024-04-30T20:39:30.154799 #8745]  INFO -- : Writing /workspace/public/assets/actioncable.esm-06609b0ecaffe2ab952021b9c8df8b6c68f65fc23bee728fc678a2605e1ce132.js
      I, [2024-04-30T20:39:30.154863 #8745]  INFO -- : Writing /workspace/public/assets/actioncable.esm-06609b0ecaffe2ab952021b9c8df8b6c68f65fc23bee728fc678a2605e1ce132.js.gz
      ** Invoke assets:clean (first_time)
      ** Invoke assets:environment
      ** Execute assets:clean

  - Done (1.869s)
  - Storing cache for /workspace/public/assets
  - Storing cache for /workspace/tmp/cache/assets
- Done (finished in 1m 49s)

[Discovering process types]
Procfile declares types -> web
===> EXPORTING
Adding layer 'heroku/nodejs-engine:dist'
Adding layer 'heroku/nodejs-engine:node_runtime_metrics'
Adding layer 'heroku/nodejs-engine:web_env'
Adding layer 'heroku/ruby:bundler'
Adding layer 'heroku/ruby:cache_public_assets'
Adding layer 'heroku/ruby:cache_tmp_cache_assets'
Adding layer 'heroku/ruby:env_defaults'
Adding layer 'heroku/ruby:gems'
Adding layer 'heroku/ruby:ruby'
Adding layer 'buildpacksio/lifecycle:launch.sbom'
Adding 1/1 app layer(s)
Adding layer 'buildpacksio/lifecycle:launcher'
Adding layer 'buildpacksio/lifecycle:config'
Adding layer 'buildpacksio/lifecycle:process-types'
Adding label 'io.buildpacks.lifecycle.metadata'
Adding label 'io.buildpacks.build.metadata'
Adding label 'io.buildpacks.project.metadata'
Setting default process type 'web'
Saving my-image-name...
*** Images (d9c260802536):
      my-image-name
Reusing cache layer 'heroku/nodejs-engine:dist'
Reusing cache layer 'heroku/ruby:bundler'
Adding cache layer 'heroku/ruby:cache_public_assets'
Adding cache layer 'heroku/ruby:cache_tmp_cache_assets'
Adding cache layer 'heroku/ruby:gems'
Reusing cache layer 'heroku/ruby:ruby'
Successfully built image 'my-image-name'

Verify that you see “Successfully built image my-image-name” at the end of the output. And verify that the image is present locally:

$ docker image ls --format "table \t\t" | grep my-image-name
d9c260802536   my-image-name            latest

What does pack build do?

Skip ahead if you want to run the application first and get into the details later.

When you run pack build with a builder, each buildpack runs a detection script to determine if it should be eligible to build the application. In our case the heroku/ruby buildpack found a Gemfile.lock file and heroku/nodejs-engine buildpack found a package.json file on disk. As a result, both buildpacks have enough information to install Ruby and Node dependencies. You can view a list of the buildpacks used in the output above:

===> DETECTING
3 of 5 buildpacks participating
heroku/nodejs-engine 3.0.5
heroku/ruby          2.1.3
heroku/procfile      3.0.1
===> RESTORING

After the detect phase, each buildpack will execute. Buildpacks can inspect your project, install files to disk, run commands, write environment variables, and more. You can see some examples of that in the output above. For example, the Ruby buildpack installs dependencies from the Gemfile automatically:

  - Running `BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`

      Fetching gem metadata from https://rubygems.org/.........
      Fetching rake 13.1.0
      Installing rake 13.1.0
      Fetching base64 0.2.0
      Fetching bigdecimal 3.1.6
      Fetching concurrent-ruby 1.2.3
      Fetching minitest 5.21.2
      Fetching mutex_m 0.2.0
      Fetching builder 3.2.4

If you’re familiar with Dockerfile you might know that many commands in a Dockerfile will create a layer. Buildpacks also use layers, but the CNB buildpack API provides for fine grained control over what exactly is in these layers and how they’re composed. Unlike Dockerfile, all images produced by CNBs can be rebased. The CNB api also improves on many of the pitfalls outlined in the satirical article Write a Good Dockerfile in 19 ‘Easy’ Steps.

Use the image

Even though we used pack and CNBs to build our image, it can be run with your favorite tools like any other OCI image. We will be using the docker command line to run our image.

By default, images will be booted into a web server configuration. You can launch the app we just built by running:

$ docker run -it --rm --env PORT=9292 -p 9292:9292 my-image-name
[1] Puma starting in cluster mode...
[1] * Puma version: 6.4.2 (ruby 3.2.4-p170) ("The Eagle of Durango")
[1] *  Min threads: 5
[1] *  Max threads: 5
[1] *  Environment: production
[1] *   Master PID: 1
[1] *      Workers: 1
[1] *     Restarts: (✔) hot (✖) phased
[1] * Preloading application
[1] * Listening on http://0.0.0.0:9292
[1] Use Ctrl-C to stop
[1] ! WARNING: Detected running cluster mode with 1 worker.
[1] ! Running Puma in cluster mode with a single worker is often a misconfiguration.
[1] ! Consider running Puma in single-mode (workers = 0) in order to reduce memory overhead.
[1] ! Set the `silence_single_worker_warning` option to silence this warning message.
[1] - Worker 0 (PID: 40) booted in 0.01s, phase: 0

Now when you visit http://localhost:9292 you should see a working web application:

Screenshot of http://localhost:9292/ Don’t forget to stop the docker container when you’re done.

Here’s a quick breakdown of that command we just ran:

  • docker run Create and run a new container from an image.
  • -it Makes the container interactive and allocates a TTY.
  • --rm Automatically remove the container when it exits.
  • --env PORT=9292 Creates an environment variable named PORT and sets it to 9292 this is needed so the application inside the container knows what port to bind the web server.
  • -p 9292:9292 Publishes a container’s port(s) to the host. This is what allows requests from your machine to be received by the container.
  • my-image-name The name of the image you want to use for the application.

So far, we’ve downloaded an application via git and run a single command pack build to generate an image, and then we can use that image as if it was generated via a Dockerfile via the docker run command.

In addition to running the image as a web server, you can access the container’s terminal interactively. In a new terminal window try running this command:

$ docker run -it --rm my-image-name bash

Now you can inspect the container interactively. For example, you can see the files on disk with ls:

$ ls
Gemfile
Gemfile.lock
Procfile
README.md
Rakefile
app
app.json
bin
build_output.txt
config
config.ru
db
lib
log
package.json
public
test
tmp
vendor

And anything else you would typically do via an interactive container session.

Image structure under the hood

Skip this section if you want to try building your application with CNBs and learn about container structure later.

If you’re an advanced Dockerfile user you might be interested in learning more about the internal structure of the image on disk. You can access the image disk interactively by using the bash docker command above.

If you view the root directory / you’ll see there is a layers folder. Every buildpack that executes gets a unique folder. For example:

$ docker run --rm my-image-name "ls /layers | grep ruby"
heroku_ruby

Individual buildpacks can compose multiple layers from their buildpack directory. For example you can see that ruby binary is present within that ruby buildpack directory:

$ docker run --rm my-image-name "which ruby"
/layers/heroku_ruby/ruby/bin/ruby

OCI images are represented as sequential modifications to disk. By scoping buildpack disk modifications to their own directory, the CNB API guarantees that changes to a layer in one buildpack will not affect the contents of disk to another layer. This means that OCI images produced by CNBs are rebaseable by default, while those produced by Dockerfile are not.

We saw before how the image booted a web server by default. This is accomplished using an entrypoint. In another terminal outside of the running container you can view that entrypoint:

$ docker inspect my-image-name | grep '"Entrypoint": \[' -A2
            "Entrypoint": [
                "/cnb/process/web"
            ],

From within the image, you can see that file on disk:

$ docker run --rm my-image-name "ls /cnb/process/"
web

While you might not need this level of detail to build and run an application with Cloud Native Buildpacks, it is useful to understand how they’re structured if you ever want to write your own buildpack.

Try CNBs out on your application

So far we’ve learned that CNBs are a declarative interface for producing OCI images (like docker). They aim to be no to low configuration and once built, you can interact with them like any other image.

For the next step, we encourage you to try running pack with the Heroku builder against your application and let us know how it went. We encourage you to share your experience by opening a discussion and walking us through what happened:

  • What went well?
  • What could be better?
  • Do you have any questions?

We are actively working on our Cloud Native Buildpacks and want to hear about your experience. The documentation below covers some intermediate-level topics that you might find helpful.

Configuring multiple languages

Language support is provided by individual buildpacks that are shipped with the builder. The above example uses the heroku/ruby buildpack which is visible on GitHub. When you execute pack build with a builder, every buildpack has the opportunity to “detect” if it should execute against that project. The heroku/ruby buildpack looks for a Gemfile.lock in the root of the project and if found, knows how to detect a node version and install dependencies.

In addition to this auto-detection behavior, you can specify buildpacks through the --buildpack flag with the pack CLI or through a project.toml file at the root of your application.

For example, if you wanted to install both Ruby, NodeJS and Python you could create a project.toml file in the root of your application and specify those buildpacks.

In file project.toml write:

[_]
schema-version = "0.2"
id = "sample.ruby+python.app"
name = "Sample Ruby & Python App"
version = "1.0.0"

[[io.buildpacks.group]]
uri = "heroku/python"

[[io.buildpacks.group]]
uri = "heroku/nodejs"

[[io.buildpacks.group]]
uri = "heroku/ruby"

[[io.buildpacks.group]]
uri = "heroku/procfile"

Ensure that a requirements.txt file, a package.json file and a Gemfile.lock file all exist and then build your application:

$ touch requirements.txt
$ pack build my-image-name --path .
22: Pulling from heroku/builder
Digest: sha256:40310993837644b85c3704acd1dede0533a9210cbefa428c68b0fc550be55e63
Status: Image is up to date for heroku/builder:22
22-cnb: Pulling from heroku/heroku
Digest: sha256:b1dedd0404b81a545d55739bf86ff95757d3a0544d272ada9f67e0eaf2b10b11
Status: Image is up to date for heroku/heroku:22-cnb
===> ANALYZING
Restoring data for SBOM from previous image
===> DETECTING
heroku/python        0.8.4
heroku/nodejs-engine 3.0.5
heroku/ruby          2.1.3
heroku/procfile      3.0.1
===> RESTORING
Restoring metadata for "heroku/nodejs-engine:dist" from app image
Restoring metadata for "heroku/nodejs-engine:node_runtime_metrics" from app image
Restoring metadata for "heroku/nodejs-engine:web_env" from app image
Restoring metadata for "heroku/ruby:gems" from app image
Restoring metadata for "heroku/ruby:ruby" from app image
Restoring metadata for "heroku/ruby:bundler" from app image
Restoring metadata for "heroku/ruby:cache_public_assets" from app image
Restoring metadata for "heroku/ruby:cache_tmp_cache_assets" from app image
Restoring data for "heroku/nodejs-engine:dist" from cache
Restoring data for "heroku/ruby:bundler" from cache
Restoring data for "heroku/ruby:cache_public_assets" from cache
Restoring data for "heroku/ruby:cache_tmp_cache_assets" from cache
Restoring data for "heroku/ruby:gems" from cache
Restoring data for "heroku/ruby:ruby" from cache
===> BUILDING

[Determining Python version]
No Python version specified, using the current default of Python 3.12.3.
To use a different version, see: https://devcenter.heroku.com/articles/python-runtimes

[Installing Python and packaging tools]
Installing Python 3.12.3
Installing pip 24.0, setuptools 68.0.0 and wheel 0.42.0

[Installing dependencies using Pip]
Running pip install

[Heroku Node.js Engine Buildpack]

[Checking Node.js version]
Node.js version not specified, using 20.x
Resolved Node.js version: 20.12.2

[Installing Node.js distribution]
Reusing Node.js 20.12.2

# Heroku Ruby Buildpack

- Metrics agent
  - Skipping install (`barnes` gem not found)
- Ruby version `3.2.4` from `Gemfile.lock`
  - Using cached version
- Bundler version `2.5.9` from `Gemfile.lock`
  - Using cached version
- Bundle install
  - Loading cache
  - Skipping `bundle install` (no changes found in /workspace/Gemfile, /workspace/Gemfile.lock, or user configured environment variables)
  - ! HELP To force run `bundle install` set `HEROKU_SKIP_BUNDLE_DIGEST=1`
- Setting default processes
  - Running `bundle list` ... (0.290s)
  - Detected rails app (`rails` gem found)
- Rake assets install
  - Detected rake (`rake` gem found, `Rakefile` found at `/workspace/Rakefile`)
  - Running `bundle exec rake -P --trace` .... (1.369s)
  - Compiling assets with cache (detected `rake assets:precompile` and `rake assets:clean` via `bundle exec rake -P`)
  - Loading cache for /workspace/public/assets
  - Loading cache for /workspace/tmp/cache/assets
  - Running `bundle exec rake assets:precompile assets:clean --trace`

      ** Invoke assets:precompile (first_time)
      ** Invoke assets:environment (first_time)
      ** Execute assets:environment
      ** Invoke environment (first_time)
      ** Execute environment
      ** Execute assets:precompile
      ** Invoke assets:clean (first_time)
      ** Invoke assets:environment
      ** Execute assets:clean

  - Done (1.084s)
  - Storing cache for /workspace/public/assets
  - Storing cache for /workspace/tmp/cache/assets
- Done (finished in 2.889s)

[Discovering process types]
Procfile declares types -> web
===> EXPORTING
Adding layer 'heroku/python:dependencies'
Adding layer 'heroku/python:python'
Reusing layer 'heroku/nodejs-engine:dist'
Reusing layer 'heroku/nodejs-engine:node_runtime_metrics'
Reusing layer 'heroku/nodejs-engine:web_env'
Reusing layer 'heroku/ruby:bundler'
Adding layer 'heroku/ruby:cache_public_assets'
Adding layer 'heroku/ruby:cache_tmp_cache_assets'
Reusing layer 'heroku/ruby:env_defaults'
Reusing layer 'heroku/ruby:gems'
Reusing layer 'heroku/ruby:ruby'
Reusing layer 'buildpacksio/lifecycle:launch.sbom'
Adding 1/1 app layer(s)
Reusing layer 'buildpacksio/lifecycle:launcher'
Adding layer 'buildpacksio/lifecycle:config'
Reusing layer 'buildpacksio/lifecycle:process-types'
Adding label 'io.buildpacks.lifecycle.metadata'
Adding label 'io.buildpacks.build.metadata'
Adding label 'io.buildpacks.project.metadata'
Setting default process type 'web'
Saving my-image-name...
*** Images (6199a303039f):
      my-image-name
Adding cache layer 'heroku/python:pip-cache'
Adding cache layer 'heroku/python:python'
Reusing cache layer 'heroku/nodejs-engine:dist'
Reusing cache layer 'heroku/ruby:bundler'
Adding cache layer 'heroku/ruby:cache_public_assets'
Adding cache layer 'heroku/ruby:cache_tmp_cache_assets'
Reusing cache layer 'heroku/ruby:gems'
Reusing cache layer 'heroku/ruby:ruby'
Successfully built image 'my-image-name'

You can run the image and inspect everything is installed as expected:

$ docker run -it --rm my-image-name bash
$ which python
/layers/heroku_python/python/bin/python

Configuring your web process with the Procfile

Most buildpacks rely on existing community standards to allow you to configure your application declaratively. They can also implement custom logic based on file contents on disk or environment variables present at build time.

The Procfile is a configuration file format that was introduced by Heroku in 2011, you can now use this behavior on your CNB-powered application via the heroku/procfile, which like the rest of the buildpacks in our builder is open source. The heroku/procfile buildpack allows you to configure your web startup process.

This is the Procfile of the getting started guide:

web: bundle exec puma -C config/puma.rb

By including this file and using heroku/procfile buildpack, your application will receive a default web process. You can configure this behavior by changing the contents of that file.