- Published on
Understanding how to build Bitnami Mindeb image and Google Distroless image
- Authors
- Name
- Jay
I use Distroless images as the base for my applications. Distroless images not only reduce the overall container size but also minimize the surface area for security vulnerabilities. Last year, I briefly explored how Bazel works in order to add a package to a Distroless image, since some applications required additional Debian packages.
Recently, Bitnami announced that secure container images will only be available under a paid plan, and that they would stop maintaining most of their previous images except for a few open-source projects. That made me wonder: Would it be possible to replace all Bitnami container images with Distroless as the base? That’s where this journey began.
To meet security requirements in the past, I often had to modify Dockerfiles from open-source projects and rebuild the images myself. I also needed to review all containers to ensure they passed security compliance checks and report on them regularly. It was a tedious and repetitive job. With Distroless images, however, much of that effort can be avoided. Since Distroless does not even include a shell by default, direct container access is restricted—and in many cases, that alone allows you to bypass a number of security compliance checks.
Understanding Bitnami Mindeb Image
Bitnami Images use minideb image as their base image. Let’s take a closer look at how a minideb base image can be built.
Before that, it’s important to understand what the debootstrap package does. The debootstrap package allows you to create minimal Debian base systems. For example, you can quickly build a Debian-based container image using Docker and debootstrap on Ubuntu:
apt install debootstrap
DEBOOTSTRAP_DIR=$(mktemp -d)
sudo debootstrap --variant=minbase --arch=amd64 trixie $DEBOOTSTRAP_DIR
sudo tar -C $DEBOOTSTRAP_DIR -cf - . | docker import - debian:stable
Now you’ve generated a Debian container image. You can check the Debian version inside the container:
$ docker run -it debian:stable cat /etc/debian_version
13.0
Which packages get installed during debootstrap?
The installation process is defined by predefined debootstrap scripts. For example, if you’re using the current stable release Trixie, you can find its script at:
cat /usr/share/debootstrap/scripts/trixie
In fact, the scripts for Bookworm, Bullseye, and Trixie all point to a shared script called debian-common:
mirror_style release
download_style apt
finddebs_style from-indices
variants - buildd fakechroot minbase
keyring /usr/share/keyrings/debian-archive-keyring.gpg
# include common settings
if [ -e "$DEBOOTSTRAP_DIR/scripts/debian-common" ]; then
. "$DEBOOTSTRAP_DIR/scripts/debian-common"
elif [ -e /debootstrap/debian-common ]; then
. /debootstrap/debian-common
elif [ -e "$DEBOOTSTRAP_DIR/debian-common" ]; then
. "$DEBOOTSTRAP_DIR/debian-common"
else
error 1 NOCOMMON "File not found: debian-common"
fi
Inside debian-common
, you’ll find three main functions. In short, they download Debian packages and initialize the system configuration:
$ cat /usr/share/debootstrap/scripts/debian-common
work_out_debs () {
}
first_stage_install () {
}
second_stage_install () {
}
The work_out_debs function
The key part is the work_out_debs function, which determines which packages get installed. By default, it selects all Debian packages with the required priority:
required="$(get_debs Priority: required)"
For example, you can check the priority of the bash package:
$ apt-cache show bash
Package: bash
Architecture: amd64
Version: 5.2.21-2ubuntu4
Multi-Arch: foreign
Priority: required
Essential: yes
Section: shells
Origin: Ubuntu
Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
Original-Maintainer: Matthias Klose <doko@debian.org>
Bugs: https://bugs.launchpad.net/ubuntu/+filebug
Installed-Size: 1900
Pre-Depends: libc6 (>= 2.38), libtinfo6 (>= 6)
Depends: base-files (>= 2.1.12), debianutils (>= 5.6-0.1)
Recommends: bash-completion
Suggests: bash-doc
Filename: pool/main/b/bash/bash_5.2.21-2ubuntu4_amd64.deb
Size: 794086
MD5sum: 627cdbb775b1a60dadd502e96e0426b1
SHA1: 8065b79389fc555b38cf71e297a259773b09c38e
SHA256: 73de311a21e094e29ac01527d2b52226cc87fde0a5b57032902251b426d92c66
SHA512: c14c2c8fa0d1ae7530efa0375845257b4ec0baa5bc42982efca2d7fa860b4e4ea3f04d31cfa328d2f250b2baeac3795c8b31aa5ff2d514ed16a5cf8c3132a590
Homepage: http://tiswww.case.edu/php/chet/bash/bashtop.html
Description-en: GNU Bourne Again SHell
Bash is an sh-compatible command language interpreter that executes
commands read from the standard input or from a file. Bash also
incorporates useful features from the Korn and C shells (ksh and csh).
.
Bash is ultimately intended to be a conformant implementation of the
IEEE POSIX Shell and Tools specification (IEEE Working Group 1003.2).
.
The Programmable Completion Code, by Ian Macdonald, is now found in
the bash-completion package.
Description-md5: 3522aa7b4374048d6450e348a5bb45d9
Task: minimal
Since bash is marked as required, you can expect it to be installed during debootstrap initialization.
Bitnami’s customization
Bitnami overrides the default debootstrap script to better suit container image generation. Here’s their version for Bullseye.
In their script, the required variable explicitly lists which Debian packages should be included:
debootstrap/bullseye
mirror_style release
download_style apt
finddebs_style from-indices
variants - container fakechroot
keyring /usr/share/keyrings/debian-archive-keyring.gpg
work_out_debs () {
required="adduser base-files base-passwd bash bsdutils coreutils dash debian-archive-keyring diffutils dpkg findutils grep gzip hostname init-system-helpers libc-bin login lsb-base mawk ncurses-base passwd sed sysv-rc tar tzdata util-linux mount"
}
This is why their build script copies these custom files into the DEBOOTSTRAP_DIR and sets the --variant=container flag when running debootstrap:
mkimage
DEBOOTSTRAP_DIR=$(mktemp -d)
cp -a /usr/share/debootstrap/* "$DEBOOTSTRAP_DIR"
cp -a /usr/share/keyrings/debian-archive-keyring.gpg "$DEBOOTSTRAP_DIR"
cp -a "${ROOT}/debootstrap/"* "${DEBOOTSTRAP_DIR}/scripts"
...
DEBOOTSTRAP_DIR="$DEBOOTSTRAP_DIR" debootstrap "${debootstrap_arch_args[@]}" --keyring "$KEYRING" --variant container --foreign "${DIST}" "$rootfsDir"
Understanding Distroless Image
This time, let’s take a closer look at how a Distroless image can be built with Bazel. I’ll be using Bazelisk to run Bazel.
sudo curl -o /usr/bin/bazelisk -L https://github.com/bazelbuild/bazelisk/releases/download/v1.27.0/bazelisk-linux-amd64
sudo chmod a+x /usr/bin/bazelisk
You can specify the version of Bazel you want to use in the .bazelversion file. Bazelisk automatically sets up the specified version and runs Bazel commands. I set it to the latest version at the time of writing:
.bazelversion
8.3.1
Setting up the Bazel workspace
Bazel requires a specific workspace structure. First, you need to add a MODULE.bazel file at the root of your project. In this file, you can declare external dependencies using predefined methods. In the examples below, we’ll use three methods:
- bazel_dep
- use_extension
- use_repo
Here’s an example MODULE.bazel
file:
MODULE.bazel
bazel_dep(name = "aspect_bazel_lib", version = "2.21.1")
bazel_dep(name = "rules_oci", version = "2.2.6")
bazel_dep(name = "rules_distroless", version = "0.5.3")
apt = use_extension("@rules_distroless//apt:extensions.bzl", "apt")
REPO = "trixie"
apt.install(
name = REPO,
lock = "//:{}.lock.json".format(REPO),
manifest = "//:{}.yaml".format(REPO),
resolve_transitive = False,
mergedusr = True,
)
use_repo(apt, REPO)
External Bazel modules
The Bazel Centry Registry hosts external Bazel modules. You can use bazel_dep
to declare which version of each external module you want to use. In this example, we’re using three modules:
bazel_dep(name = "aspect_bazel_lib", version = "2.21.1")
bazel_dep(name = "rules_oci", version = "2.2.6")
bazel_dep(name = "rules_distroless", version = "0.5.3")
Configuring Debian repositories
Next, let’s configure the Debian repositories and packages to be installed in the Distroless image. We’ll use two methods:
- use_extension
- use_repo.
Here, we configure only the current Debian stable release:
apt = use_extension("@rules_distroless//apt:extensions.bzl", "apt")
REPO = "trixie"
apt.install(
name = REPO,
lock = "//:{}.lock.json".format(REPO),
manifest = "//:{}.yaml".format(REPO),
resolve_transitive = False,
mergedusr = True,
)
use_repo(apt, REPO)
The trixie.lock.json
file specifies the exact package versions you want installed. For example, the latest version of ca-certificates in Debian Trixie is 20250419 at the time of writing. If you need to patch vulnerabilities (CVEs), you’ll need to update this lock file.
trixie.lock.json
{
"packages": [
{
"arch": "arm64",
"dependencies": [],
"key": "ca-certificates_20250419_arm64",
"name": "ca-certificates",
"sha256": "ef590f89563aa4b46c8260d49d1cea0fc1b181d19e8df3782694706adf05c184",
"url": "https://snapshot.debian.org/archive/debian/20250812T210203Z/pool/main/c/ca-certificates/ca-certificates_20250419_all.deb",
"version": "20250419"
}
]
}
I copied both trixie.lock.json
and trixie.yaml
from the Distroless GitHub repo. The trixie.yaml
file must follow the format described in the rules_distroless documentation.
Defining the base image
Now, let’s write a base.bzl file to define the base image using our external modules.
aspect_bazel_lib
is used to create tar files.rules_distroless
is used to define the Debian filesystem.rules_oci
is used to build the final OCI container images.
bazel.bzl
load("@aspect_bazel_lib//lib:tar.bzl", "tar")
load("@rules_distroless//distroless:defs.bzl", "cacerts", "group", "home", "locale", "os_release", "passwd")
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_image_index")
load(":variables.bzl", "MTIME", "NOBODY", "NONROOT", "ROOT", "USER_VARIANTS")
def base_image():
tar(
name = "rootfs",
srcs = [],
args = [
"--format",
"gnutar",
],
compress = "gzip",
mtree = ["./ type=dir uid=0 gid=0 time=0.0"],
)
tar(
name = "tmp",
srcs = [],
# original tmp.tar was created on a gnutar, mimic that.
args = [
"--format",
"gnutar",
],
compress = "gzip",
mtree = ["./tmp gname=root uname=root time=1501783453.0 mode=1777 gid=0 uid=0 type=dir"],
)
os_release(
name = "os_release_debian13",
content = {
"PRETTY_NAME": "Distroless",
"NAME": "Debian GNU/Linux",
"ID": "debian",
"VERSION_ID": "13",
"VERSION": "Debian GNU/Linux 13 (trixie)",
},
time = MTIME,
)
locale(
name = "locale_debian13_arm64",
charset = "C.utf8",
package = "@trixie//libc-bin/arm64:data",
time = MTIME,
)
cacerts(
name = "cacerts_debian13_arm64",
package = "@trixie//ca-certificates/arm64:data",
time = MTIME,
)
group(
name = "group",
entries = [
{
"name": "root", # root_group
"gid": ROOT,
"password": "x",
},
{
"name": "nobody", # nobody_group
"gid": NOBODY,
"password": "x",
},
{
"name": "tty", # tty_group
"gid": 5,
"password": "x",
},
{
"name": "staff", # staff_group
"gid": 50,
"password": "x",
},
{
"name": "nonroot", # nonroot_group
"gid": NONROOT,
"password": "x",
},
],
time = MTIME,
)
passwd(
name = "passwd",
entries = [
{
"gecos": ["root"],
"gid": ROOT,
"shell": "/sbin/nologin",
"home": "/root",
"uid": ROOT,
"password": "x",
"username": "root",
},
{
"gecos": ["nobody"],
"gid": NOBODY,
"home": "/nonexistent",
"shell": "/sbin/nologin",
"uid": NOBODY,
"password": "x",
"username": "nobody",
},
{
"gecos": ["nonroot"],
"gid": NONROOT,
"home": "/home/nonroot",
"shell": "/sbin/nologin",
"uid": NONROOT,
"password": "x",
"username": "nonroot",
},
],
)
home(
name = "home",
dirs = [
{
"home": "/root",
"uid": ROOT,
"gid": ROOT,
"mode": 700,
},
{
"home": "/home",
"uid": ROOT,
"gid": ROOT,
"mode": 755,
},
{
"home": "/home/nonroot",
"uid": NONROOT,
"gid": NONROOT,
"mode": 700,
},
],
)
for (user, _, _) in USER_VARIANTS:
oci_image_index(
name = "static_" + user + "_" + "debian13",
images = [
"static_" + user + "_" + "arm64" + "_" + "debian13",
],
)
for (user, uid, workdir) in USER_VARIANTS:
oci_image(
name = "static_" + user + "_" + "arm64" + "_" + "debian13",
env = {
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
# allows openssl to find the certificates by default
# TODO: We should run update-ca-certifaces, but that requires "openssl rehash"
# which would probably need to be run inside the container
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
},
tars = [
"@trixie//base-files/arm64",
"@trixie//netbase/arm64",
"@trixie//tzdata/arm64",
"@trixie//media-types/arm64",
"//:rootfs",
"//:passwd",
"//:home",
"//:group",
# Create /tmp, too many things assume it exists.
# tmp.tar has a /tmp with the correct permissions 01777
"//:tmp",
":nsswitch.tar",
"//:os_release_" + "debian13",
"//:cacerts_" + "debian13" + "_" + "arm64",
],
user = "%d" % uid,
workdir = workdir,
os = "linux",
architecture = "arm64",
)
Variables file
You can keep constants and variables in a separate {name}.bzl
file and load them into your Bazel rules:
base.bzl
load(":variables.bzl", "MTIME", "NOBODY", "NONROOT", "ROOT", "USER_VARIANTS")
variables.bzl
NOBODY = 65534
NONROOT = 65532
ROOT = 0
MTIME = "946684800"
USER_VARIANTS = [("root", 0, "/"), ("nonroot", NONROOT, "/home/nonroot")]
Adding packages to the image
Since we already declared the Debian repositories in MODULE.bazel
with rules_distroless
, you can add packages by referencing them like @trixie//base-files/arm64
.
oci_image(
name = "static_" + user + "_" + "arm64" + "_" + "debian13",
env = {
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
# allows openssl to find the certificates by default
# TODO: We should run update-ca-certifaces, but that requires "openssl rehash"
# which would probably need to be run inside the container
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
},
tars = [
"@trixie//base-files/arm64",
"@trixie//netbase/arm64",
"@trixie//tzdata/arm64",
"@trixie//media-types/arm64",
"//:rootfs",
"//:passwd",
"//:home",
"//:group",
# Create /tmp, too many things assume it exists.
# tmp.tar has a /tmp with the correct permissions 01777
"//:tmp",
":nsswitch.tar",
"//:os_release_" + "debian13",
"//:cacerts_" + "debian13" + "_" + "arm64",
],
user = "%d" % uid,
workdir = workdir,
os = "linux",
architecture = "arm64",
)
Interestingly, Bazel files allow for loops. This is useful, for example, when you want to build multiple images for different users or architectures:
def base_image():
...
for (user, uid, workdir) in USER_VARIANTS:
...
BUILD file
Finally, you need a BUILD file to wire everything together. Here, we import the function from base.bzl and call it:
BUILD
load(":base.bzl", "base_image")
base_image()
Building the image
Now we’re ready to build the Distroless image with Bazel:
bazelisk build //...
If the build succeeds, you’ll find two OCI images created:
$ ls bazel-bin/static_nonroot_arm64_debian13
blobs index.json oci-layout
$ ls bazel-bin/static_root_arm64_debian13
blobs index.json oci-layout
You can load an OCI image into the Docker daemon using Skopeo:
brew install skopeo
skopeo copy oci:./bazel-bin/static_nonroot_arm64_debian13 docker-daemon:my-distroless-image:stable
Conclusion
The Bitnami Minideb image is used as the base image in the Bitnami project. I learned that it is built using custom scripts on top of the debootstrap package. By reviewing these scripts, you can see which Debian packages are included.
Google’s Distroless image goes even further by including an even smaller set of Debian packages. I took a closer look at how Distroless images can be generated with Bazel. Based on this understanding, I will try replacing the Minideb image with a Distroless image.