Before you can run a container, you need to build an image — a packaged copy of your application together with everything it needs to run. An image is described by a Dockerfile. Let's break down how this works under the hood and how to write a Dockerfile for a Spring Boot application from scratch.
The problem: "it works on my machine, but not on the server"
Picture this: you wrote a service, ran it on your laptop — everything is fine. You ship it to the server — it crashes. It turns out your laptop has Java 21, while the server has Java 17. Or some native library is installed locally but not on the target machine. Or a configuration file is in the wrong place.
This is the classic "works on my machine" problem. The solution is to package the application together with its runtime and all its dependencies into a single image. Then, everywhere Docker is running, the behavior will be identical: on your laptop, in CI, on the server.
An image is an immutable template from which containers are created. One image — as many containers as you like based on it.
What an image and layers are
A Docker image is not just an archive of files. Inside, it consists of layers, stacked one on top of another.
Each layer is the result of a single instruction in the Dockerfile. A layer records changes to the file system: added files, installed packages, modified settings. The final image is a stack of layers that Docker merges into a single file system.
A short formula: an image = a base image + a set of layers, each of which corresponds to one Dockerfile instruction.
Why layers? For cache reuse. If you changed only your application code, Docker won't re-download the base image and reinstall dependencies — those layers already exist locally. Only what changed is rebuilt, along with everything that comes after it. This significantly speeds up repeated builds.
[ eclipse-temurin:21-jre ] ← base image (already ready)
+
[ WORKDIR /app ] ← layer 1
+
[ COPY app.jar app.jar ] ← layer 2
+
[ ENTRYPOINT [...] ] ← layer 3
=
your image
The Dockerfile — build instructions
A Dockerfile is a text file with a set of instructions. Docker reads it top to bottom and executes each instruction, creating a new layer.
Let's go through the main instructions using a real example.
FROM — the base image
FROM eclipse-temurin:21-jre
FROM is the first and mandatory instruction. It tells Docker which image to build upon. eclipse-temurin:21-jre is the official image with JRE 21 (runtime only, without the JDK and compiler). That's enough to run a Spring Boot jar file.
21-jre is a tag: the image version. Always specify a concrete tag rather than latest — otherwise the build can break when a new version is released.
WORKDIR — the working directory
WORKDIR /app
WORKDIR sets the directory inside the container in which subsequent instructions (COPY, RUN, CMD) will run. If the directory doesn't exist, it is created automatically. It's like running cd /app, but for Docker.
COPY — copying files
COPY build/libs/app.jar app.jar
COPY copies files from your local file system (left) into the image (right). Here we copy the built jar into the container's working directory under the name app.jar.
The first argument is a path relative to the build context (usually the directory from which you run docker build). The second is a path inside the image.
RUN — running commands at build time
RUN apt-get update && apt-get install -y curl
RUN executes a command during the image build and saves the result as a new layer. It is used to install packages, create directories, and perform any environment preparation.
Multiple commands are best combined into a single RUN instruction with && — this creates fewer layers and leaves no intermediate caches in the image.
ENV — environment variables
ENV JAVA_OPTS="-Xmx512m -Xms256m"
ENV sets environment variables inside the image. They are available both at build time and when the container runs. Handy for tuning the JVM or passing configuration to the application.
Variables that should change at runtime (passwords, database URLs) are better passed via docker run -e or docker compose — don't bake them into the image.
EXPOSE — documenting the port
EXPOSE 8080
EXPOSE declares which port the application listens on inside the container. This is documentation only — actual port forwarding is configured when the container is run (docker run -p). But EXPOSE helps a reader of the Dockerfile understand how to work with the application.
CMD and ENTRYPOINT — running the application
These are the most commonly confused instructions. Both define what to execute when the container starts, but in different ways.
CMD is the default command. It can be replaced when the container is run by passing a different command to docker run. For example:
CMD ["java", "-jar", "app.jar"]
ENTRYPOINT is a fixed entry point. The command from ENTRYPOINT always runs; whatever is passed via docker run is appended to it as arguments rather than replacing it. Example:
ENTRYPOINT ["java", "-jar", "app.jar"]
The most common technique is a combination: ENTRYPOINT fixes the executable, and CMD provides default arguments (which can be overridden):
ENTRYPOINT ["java"]
CMD ["-jar", "app.jar"]
For simple cases a single ENTRYPOINT is enough. Use the exec form (a JSON array, as above) — it launches the process directly, without an intermediate shell, which matters for correct handling of container stop signals.
A complete Dockerfile for Spring Boot
Putting it all together:
FROM eclipse-temurin:21-jre
WORKDIR /app
# copy the built jar (assumes: ./gradlew bootJar has already been run)
COPY build/libs/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
This is a minimal working Dockerfile. It takes a ready jar, places it into the image, and runs it when the container starts.
Building the image: docker build
You can build an image from a Dockerfile with the command:
# build the image and assign the tag my-app:1.0
docker build -t my-app:1.0 .
The -t flag (for tag) sets the image name and tag. The dot at the end is the build context: the directory whose contents Docker can use in COPY instructions. Usually this is the current project directory.
To view the created image:
docker images
After building, the image can be run:
docker run -p 8080:8080 my-app:1.0
The -p 8080:8080 option forwards the port: <host port>:<container port>.
Layer cache and instruction order
Docker caches layers. If a layer hasn't changed on a repeated build, it is taken from the cache rather than rebuilt. But as soon as one layer changes, all subsequent ones are rebuilt.
From this follows an important rule: put instructions that rarely change higher up; put what changes often lower down.
In a Spring Boot application the jar file changes on every build, so COPY app.jar comes at the end. If dependency installation via RUN came before it, that layer would also have to be rebuilt on every code change. The right order saves minutes on each iteration.
For even more efficient caching (separating dependencies from application code), a multi-stage build is used — that's a separate topic, covered in the article on layers and multi-stage builds.
In short
- An image is an immutable template for running containers; it consists of layers.
- Each layer = one
Dockerfileinstruction; unchanged layers are taken from the cache on a repeated build. FROMsets the base image;eclipse-temurin:21-jreis the standard choice for Java 21.COPYcopies files,RUNruns commands at build time,ENVsets environment variables.ENTRYPOINTfixes the entry point;CMDprovides default arguments (which can be overridden at run time).docker build -t name:tag .builds the image;.is the build context.- Put rarely changing instructions higher in the
Dockerfile— that way the cache is used more efficiently.
What to read next
- What Docker is and why you need it — if you're not yet familiar with the basic concepts of containerization.
- Containerizing a Spring Boot application — a step-by-step walkthrough from building the jar to running it in a container.
- Multi-stage builds and layer optimization — how to reduce image size and speed up builds with multi-stage builds.