Say Goodbye to Tedious Docker Commands: Embrace Docker to Bake Images
Building and pushing container image with Docker is easy. Right? We define a Dockerfile and we execute a command like docker image build ...
. Docker file is easy to define and the rest is just a CLI command. How hard can it be?
Well… It can be hard or, at least, tedious.
Imagine that we have to build images for multiple platforms, that each of those images should be released both as a specific version but also as latest. Then add to that the situation that we need to build more than one image, let’s say a backend and a frontend.
How many commands do we need to execute and how many arguments should each of those commands have? Can we remember all those arguments and are we willing to execute a bunch of commands?
That simple example already shows that building and pushing container images can be hard and tedious. The good news is that there is a better way. There is a declarative way to do all that.
Setup
git clone https://github.com/vfarcic/silly-demo
cd silly-demo
git pull
git fetch
git switch docker-bake
Watch Nix for Everyone: Unleash Devbox for Simplified Development if you are not familiar with Devbox. Alternatively, you can skip Devbox and install all the tools listed in
devbox.json
yourself.
devbox shell
Watch The Future of Shells with Nushell! Shell + Data + Programming Language if you are not familiar with Nushell. Alternatively, you can inspect the
dot.nu
script and transform the instructions in it to Bash or ZShell if you prefer not to use that Nushell script.
chmod +x dot.nu
./dot.nu setup bake
source .env
Building and Pushing Docker Images Without Bake
Let’s try to reproduce the scenario I mentioned earlier. We are going to build and push images for both the backend and the frontend application. We’ll make sure that both specific and the latest tags are used. Finally, we’ll do all that both for AMD and ARM architectures.
Here’s how we would do all that using the commands most of us are very familiar with.
We’ll build
a tag
for whatever is the name of the image stored in the environment variable IMAGE
. It will be version v0.0.1
and it will be built both for amd64
and arm64
platforms. We want to push
that image to the registry and we’ll set the context to be the current directory (.
). There is already Dockerfile in there and, since Docker assumes it by default, there is no need to specify --file
.
docker image build --tag ${IMAGE}:v0.0.1 \
--platform linux/amd64,linux/arm64 --push .
A few moments later, the image was built and pushed to the registry.
That was easy. Right?
It would be even easier if we wouldn’t need to specify the platforms and the context directory since they are always the same for those images. Executing that command runs a risk of forgetting or misstyping some of those arguments. Still, it wasn’t that bad. Right?
Now, let’s make sure that same image is available as the latest version as well. Even though I don’t think we should be encouraging people to use the latest, that’s what many are doing so we’ll do it as well.
To publish that image as latest, we can repeat the same command as before but, this time, with latest
as the tag
version.
docker image build --tag ${IMAGE}:latest \
--platform linux/amd64,linux/arm64 --push .
That was a bit more annoying, especially since we are likely to do that every single time. Normally, I would put both into a script but, as we’ll see soon, there is a better way.
We’re not done yet.
Next, let’s say that we would like to do the same for the frontend application with code that happens to reside in the same Git repo.
We’ll repeat the first command but, this time, the tag should be different. We’ll add -frontend
to it. Also, the context is different, so we need to change it to ./frontend
.
docker image build --tag ${IMAGE}-frontend:v0.0.1 \
--platform linux/amd64,linux/arm64 --push ./frontend
After a while, we got our third image, and my annoyance of having to run similar, yet sufficiently different commands is converting itself from “I don’t mind” to “this is silly”.
There’s more though.
Now we need the latest tag of frontend image.
So, let’s re-run the previous command but, before we do that, change v0.0.1
to latest
.
docker image build --tag ${IMAGE}-frontend:latest \
--platform linux/amd64,linux/arm64 --push ./frontend
Let’s confirm that all the images were indeed built.
docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ghcr.io/vfarcic/silly-demo latest 2c17e2d577c6 45 years ago 74.4MB
ghcr.io/vfarcic/silly-demo v0.0.1 0925e74ebccd 45 years ago 74.4MB
ghcr.io/vfarcic/silly-demo-frontend v0.0.1 f728edc98c37 45 years ago 1.41GB
ghcr.io/vfarcic/silly-demo-frontend latest 9309fecc251a 45 years ago 1.41GB
They are all there. There are two tags of silly-demo
and two tags of silly-demo-frontend
. Each of those were built and pushed
That’s it. We did it, and I’m annoyed by having to run similar commands over and over again while, at the same time, risking making a typo or forgetting some of the arguments.
Here’s what I want.
I want to specify all those variations somewhere and just tell Docker: “Build whatever is defined there. Don’t ask me anything but the specific version I’m building since that is the only thing that changes over time. Do it all at once, unless I tell you otherwise.”
Is that too much to ask?
I can easily make my own script that does all that but I would prefer if there is a standard and out-of-the-box way to do something that is fairly common. I’m surely not the only one.
Luckily, Docker recently released GA version of the feature that does just that. That feature is called Docker Bake.
Building and Pushing Docker Images With Bake
Here’s how we would accomplish the same outcome with Docker Bake.
We’ll export
environment variable TAG
to version we want to build and push,…
export TAG=v0.0.2
…and we’ll execute docker buildx bake
with the --push
argument that should be self-explanatory.
docker buildx bake --push
The output is as follows (truncated for brevity).
[+] Building 15.2s (52/52) FINISHED docker:desktop-linux
=> [internal] load local bake definitions 0.0s
=> => reading docker-bake.hcl 628B / 628B 0.0s
=> [frontend internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 321B 0.0s
=> [backend internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 394B 0.0s
=> [frontend linux/arm64 internal] load metadata for docker.io/library/node:22-alpine 0.5s
=> [frontend linux/amd64 internal] load metadata for docker.io/library/node:22-alpine 0.5s
=> [backend linux/arm64 internal] load metadata for docker.io/library/golang:1.23.6-alpine 1.0s
=> [backend linux/amd64 internal] load metadata for docker.io/library/golang:1.23.6-alpine 0.9s
...
=> CACHED [frontend linux/arm64 2/6] WORKDIR /app 0.0s
=> CACHED [frontend linux/arm64 3/6] COPY package.json . 0.0s
...
=> [frontend] pushing ghcr.io/vfarcic/silly-demo-frontend:v0.0.2 with docker 2.1s
...
=> [backend] pushing ghcr.io/vfarcic/silly-demo:v0.0.2 with docker 2.1s
...
=> [frontend] pushing ghcr.io/vfarcic/silly-demo-frontend:latest with docker 2.0s
...
=> [backend] pushing ghcr.io/vfarcic/silly-demo:latest with docker 2.0s
...
View build details:
frontend: docker-desktop://dashboard/build/desktop-linux/desktop-linux/7b9ya9a8z5kjfcbxkctcvadil
backend: docker-desktop://dashboard/build/desktop-linux/desktop-linux/mb5fn006ywhpduwbeqkqs1syb
That’s it. We built and pushed both backend and frontend images with specific versions as well as latest. We built those for both ARM and AMD platforms. It found correct contexts and Dockerfiles, and whatever else was needed. To make it even sweater, all that was happening in parallel so the whole process was not only easier and less error-prone but also faster than before.
I love easy, especially when easy also means with less chance to produce human-caused issues. I love this.
To be safe, let’s confirm that it really did what it’s supposed to do by listing all the images.
docker image ls
The output is as follows.
REPOSITORY TAG IMAGE ID CREATED SIZE
ghcr.io/vfarcic/silly-demo latest 257a3e47e31e 45 years ago 74.4MB
ghcr.io/vfarcic/silly-demo v0.0.2 257a3e47e31e 45 years ago 74.4MB
ghcr.io/vfarcic/silly-demo v0.0.1 0925e74ebccd 45 years ago 74.4MB
ghcr.io/vfarcic/silly-demo-frontend v0.0.1 f728edc98c37 45 years ago 1.41GB
ghcr.io/vfarcic/silly-demo-frontend latest 993e69a5397c 45 years ago 1.41GB
ghcr.io/vfarcic/silly-demo-frontend v0.0.2 993e69a5397c 45 years ago 1.41GB
We can see that silly-demo
and silly-demo-frontend
were built with both v0.0.2
and latest
tags. They were also pushed to the registry, and all it took is a single command with no arguments except for the environment variable with the version we need.
Now, let’s say that we are interested in building only the frontend, without backend instead of building it all.
We can do that by specifying the new TAG
,…
export TAG=v0.0.3
…and executing the same command as before but, this time, with frontend
as the target.
docker buildx bake frontend
That’s it. Only frontend was built and, since we did not specify the push
argument, it was not pushed to the registry.
I think that Docker Bake is awesome. It’s just what we needed, especially when working with multiple images, platforms, tags, contexts, or anything else that results in more than a simple docker image build ...
command.
Now, let’s see how it works.
How Docker Bake Works?
All we need to make Docker Bake work is a manifest that contains all the information it needs to do the “magic”.
Here’s an example.
docker buildx bake --print | jq .
The output is as follows.
{
"group": {
"default": {
"targets": [
"backend",
"frontend"
]
}
},
"target": {
"backend": {
"context": ".",
"dockerfile": "Dockerfile",
"args": {
"SOURCE_DATE_EPOCH": "315532800",
"VERSION": "v0.0.3"
},
"tags": [
"ghcr.io/vfarcic/silly-demo:v0.0.3",
"ghcr.io/vfarcic/silly-demo:latest"
],
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"frontend": {
"context": "frontend",
"dockerfile": "Dockerfile",
"args": {
"SOURCE_DATE_EPOCH": "315532800",
"VERSION": "v0.0.3"
},
"tags": [
"ghcr.io/vfarcic/silly-demo-frontend:v0.0.3",
"ghcr.io/vfarcic/silly-demo-frontend:latest"
],
"platforms": [
"linux/amd64",
"linux/arm64"
]
}
}
}
That JSON has two targets, backend
and frontend
. Each of them specifies the context
, dockerfile
, args
, tags
, and platforms
, which are all, essentially, what we would normally pass to docker image build as arguments, except in the case of tags which we had to build and push separately.
As a result, we can bake either the backend
or the frontend
.
On top of those is the group
called default
. As a naming convention, if we do not specify a target, Docker Bake will assume it is the default
one. But, in this case, we are not talking about a target but a group which allows us to group targets together. That’s why when we executed docker buildx bake without any arguments, it baked both backend
and frontend
.
All in all, if we define JSON like that one, we can bake either backend or frontend or both.
There are problems with that one though.
Quite a few things are repeated, and I hate repetition. The dockerfile
, VERSION
argument, and platforms
are the same for both targets. Moreover, the tags follow the same pattern that is image with version and latest.
We can improve on that, but before we do, it might be important to understand that the JSON we just saw is the format Docker Bake expects and we have a few options how to generate it. We could certainly write it as-is but, that would be a pain. Instead, we can use HCL, JSON, or YAML to generate it.
HCL seem to be the format Docker Bake put most effort into, so let’s use that.
Here’s an example.
cat docker-bake.hcl
The output is as follows.
variable "IMAGE" {
default = "ghcr.io/vfarcic/silly-demo"
}
variable "TAG" {
default = "dev"
}
target "default" {
name = item.name
matrix = {
item = [{
name = "backend"
context = "."
tags = ["${IMAGE}:${TAG}", "${IMAGE}:latest"]
}, {
name = "frontend"
context = "./frontend"
tags = ["${IMAGE}-frontend:${TAG}", "${IMAGE}-frontend:latest"]
}]
}
tags = item.tags
dockerfile = "Dockerfile"
context = item.context
platforms = ["linux/amd64", "linux/arm64"]
args = {
VERSION = TAG
}
}
We have the target
called default
which uses a matrix
to dynamically create the targets we saw in final Json we explored earlier.
That matrix is essentially an array of items with the name
, context
, and tags
.
A separate target will be generated for each item in that matrix and we are using the values of those items to populate the final name
, tags
, and context
. On the other hand, the dockerfile
, platforms
, and args
are the same for all targets.
Moreover, we have variables IMAGE
and TAG
. Each of those have default values that can be overwritten by environment variables. That’s how we built the specific tag.
We are using those variables to construct the tags
in the matrix as well as to overwrite the argument VERSION
inside Dockerfile itself.
Without entering into the discussion whether you should be building images with Docker or something else, I must say that I love it. It’s awesome and if Docker is your container image builder of choice, you should definitely give it a try.
Destroy
exit