Building With Docker: Images, Containers, Multi-Stage builds & Optimization


In this blog, I'll cover how you create a Docker Image, run & manage Containers, What are Multi-Stage builds, few optimization tricks and much more with a simple & practical Golang app example.
Prerequisites.
No as such prerequisites required apart from the following:
You've Docker install on your system.
You're are familiar with few basic CLI commands (though they will be covered again in this blog).
Plus, you can also read my previous blog on Docker Basics: Beginner's Introduction.
Understanding Image Layers & Caching.
As a recap from my previous blog, Docker Images are templates which contains the instructions for creating a Docker Container. We first build the Image and use that to create a Container.
The instructions are written step-by-step and are called as Layers in Docker terms.
Images are by default immutable, which means, if we make changes in any of the Layers a new Image will get created rather than the old Image getting updated.
The Layers are stacked on top of each other, meaning they follow Top to Bottom down approach of execution. Each Layer depends on the previous Layer, meaning if you made any changes on the previous Layer then all the Layers below it will be rebuild again.
Since only the changed Layer and the Layers below it are rebuilt, Docker caches and stores the previous unchanged layers to speed up subsequent builds. This is called Layer Caching.
In the example above, Layer 3 was changed, hence all the below layers got rebuild but not the previous layers. Also a new Image got created.
In many production applications, we have multiple steps for building an Image. To take an example, let's say our app has multiple development environments, Production, Staging, Local. But all of these environments have the same starting Layers.
So, we can club those Layers together and use them as a starting point for other environments. This is also called as Builder Layer or Base Image depending on the context. Builder Layer if its used as a starting Layer for creating multiple environment Images for the same app, and Base Image if its used as a starting Image for creating multiple Images for different apps.
We'll look into Builder Layer in Multi-Stage builds section of this blog.
Writing a DockerFile.
We talked about Layers in the previous section. These Layers are written inside a special file which is called a DockerFile.
To explain how to write a DockerFile, we will use a simple Golang app example.
Note: No Golang experience is required. You can follow along with the explanation and use that knowledge to any of your apps.
This is the folder structure and inside which we have placed our DockerFile.
.
├── Dockerfile
├── go.mod
└── main.go
A simple program which starts a http
server on port 8080
and when you hit endpoint /
will return the Hello, Docker with Go!
message.
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, Docker with Go!")
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Server running on port 8080...")
http.ListenAndServe(":8080", nil)
}
Response from the API.
> curl -X GET http://localhost:8080/
Hello, Docker with Go!
Now, let's talk about the DockerFile. What all Layers we will write to create an Image for our app.
DockerFile follows a specific syntax and each Layer starts with an Instruction specifier like FROM
, CMD
, COPY
etc. You can check the full list of instruction over here Official DockerFile Reference.
# Layer 1: Base Image
FROM golang:1.24-alpine
# Layer 2: Set environment variables
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
# Layer 3: Set working directory
WORKDIR /app
# Layer 4: Copy & install dependencies
COPY go.mod ./
RUN go mod download
# Layer 5: Copy source code
COPY . .
# Layer 6: Build binary file
RUN go build -o main
# Expose port
EXPOSE 8080
# Run the binary file
CMD ["./main"]
Now, let's understand each of the Layers & Instruction in detail.
Layer 1: Base Image
FROM golang:1.24-alpine
Since, using the DockerFile we will create a Container which are Isolated. So, we require a building block, a Base image, on top of which we construct our app.
Here, we've used the official
golang
alpine image which is present on the Docker Hub. This Image will give us all the necessarygolang
related binaries & libraries for running our app.We use the
FROM
instruction for using the base image.Layer 2: Set environment variables
To set any app specific environment variables we can use the
ENV <key>=<value>
instruction.Layer 3: Set working directory
WORKDIR
instruction is used for changing the directory in which we would like to execute the below layers. We simply doWORKDIR <folder_name>
.Layer 4: Copy & install dependencies
COPY go.mod ./ RUN go mod download
COPY <src> <dest>
lets you copy the files from your current directory to working directory.Layer 5: Copy source code we copied all the files of our folder to the working directory.
Layer 6: Build binary file
RUN <cmd>
using this instruction we can run any command which we want to be performed inside the working directory.Expose port
EXPOSE <port>
using this we tell Docker that our container will listen to this port. This step doesn't creates a Layer.Run the binary file
CMD ["./main"]
CMD
instruction is used for specifying what command you need to run when starting the Container. This step doesn't creates a Layer.
An important difference between CMD & RUN,
RUN executes the specified command at the Build time of the Image,
CMD executes the specified command at the Container Run time.
The above Instruction are very commonaly used inside a DockerFile and you will use these quite a lot when building your own DockerFile.
Building Image & Running Containers.
Now, let's run few commands to build our Image.
To build our Image, we run this command
docker build .
The .
specifies the build context. Since, we are in the same directory as of our DockerFile
we don't need to change the build context. After running this we will get the output.
You read the output and see what all steps have been executed.
If we re-run the command, you'll see that all the steps were Cached because we didn't made any changes.
To see all the Images in our system, we can run this command
docker images
It returned us the Image of our app with an ID. We can use the ID to start the container but it will be difficult to find which app is ours if you build multiple images.
For that we can Tag our image with some name. We run the build command like this
docker build -t <name> ./
Now, if your run docker images
you will see our Image got new name myapp.
Now, let's run the Container using our Image. For that we use the following command
docker run <image_name or image id>
Nice, our app is running let's test the API by hitting the curl
curl -X GET http://localhost:8080/
Hey, but we got this as a response.
We got this response because we didn't mount our system port 8080 to the container's exposed port. For that we run the following command
docker run -p <system_port>:<container_port> <image_name or image id>
Now, hit the API again and you will see this response.
Now, here are some more commands which you can run and play with.
docker ps
to get list of all running containers.docker ps -a
to get list of all the containers.docker stop <container_id>
stops the container.docker remove <container_id>
to delete a container.docker rmi <image_id>
deletes the image.docker exec -it <container_id> <cmd>
execute cmd inside the container
You don't need to memorize all these commands, you can just look them up here Official CLI Docs.
Multi-Stage builds.
If we look into our previous example of our DockerFile, we'll find that we can divide our DockerFile primarily in two separate operations.
First being a Build & Compile step & second being the Execution step.
Now, if we look at the size of our myapp
image, we will see its quite large for such a simple app.
To reduce & optimize the construction of our Images, Docker provides us with a functionality of Multi-Stage builds using which we can divide our DockerFile into multiple Stages such that the final Image will be of lower size.
To understand this, let's take the previous example and convert it into Multi-stage build.
# Stage 1: Build
FROM golang:1.24-alpine AS builder
ENV CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN go build -o main
# Stage 2: Final image (runtime only)
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]
Now, let's understand both of these Stages in detail.
Stage 1: Build
FROM
instruction marks the beginning of a Stage using theAS <stage_name>
.In this first stage, we are creating the binary file of our Golang app.
Stage 2: Final image
In this stage we are using a lightweight Base image
alpine
for just executing our binary file.COPY --from=<stage_name> <source> <dest>
, here we are specifying that we only need the binary file to be copied into the final image.
How this works is?
When you run the build command, Docker will sequentially execute the stages but only the final Stage will be created as an Image.
The builder stages will still remain in the Cached state and will be used for subsequent builds.
Now, let's run the build command & check the size of the image.
docker build -t mymultistageapp ./
We observed a significant reduction in the size of our image.
Using Multi-Stage builds in our DockerFile we ensure that:
Our Images are lightweight & optimized in size.
DockerFile is split efficiently in multi stages.
Only necessary files are shipped into the final Image.
Optimizing Images & Best Practices.
Few of things which we can do to make are Images efficient & follow best practices are:
Using
.dockerignore
for excluding unnecessary files.We can create a
.dockerignore
file for excluding unwanted files.*.log *.env .git vendor/ node_modules/
Using Multi-Stage builds and correctly spliting DockerFile into distinct Stages.
Rebuilding Images & defining Layers correctly for leveraging Build Cache.
You can refer to more of these here Official Docs for Best practices.
Summary.
In this blog, we covered a lot of important concepts for building and working with Docker Images. In upcoming blogs, we will see more advanced topics like Docker Compose, Working example of a Fullstack (3-Tier architecture) app on Docker, Networking, Volumes and much more.
So, keep an eye on this series Docker In Depth.
Conclusion.
Thanks for reading it, and if you liked the blog, please consider following me on my socials.
X / Twitter: @ArcadeBuilds
Github: @the-arcade-01
Subscribe to my newsletter
Read articles from Aashish Koshti directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Aashish Koshti
Aashish Koshti
I'm Product Engineer, specialized in building & scaling products from 0 to 1 & beyond.