CodeGym /Courses /Docker for Spring /Dockerfile instructions: FROM– EXPOSE

Dockerfile instructions: FROM– EXPOSE

Docker for Spring
Level 4, Lesson 0
Available

1. Dockerfile as a "recipe" executed by a robot

If Dockerfile still feels to you like some kind of magic scroll, today we will turn it into an ordinary working tool. Imagine: Dockerfile is a pizza recipe, and Docker is a robot chef that does everything literally and in the specified order. You write the steps, the robot executes them from top to bottom, and the result is an image that you can then run as many times as you want on any machine.

What matters here is to immediately grasp one simple thing: Dockerfile describes how to build an image. Starting a container is a separate story. Some instructions are executed during docker build, while others affect how the container behaves when you run docker run. If you do not see this boundary, typical questions quickly arise, like: "why does RUN java -jar app.jar not start the application when the container starts?"

To make it clearer, here is the diagram (simple, but honest):

flowchart LR
    A["Dockerfile + build context"] -->|docker build| B["Docker image"]
    B -->|docker run| C["Docker container"]
    C --> D["Application process, for example: java -jar app.jar"]

In this lecture, we will look at the main Dockerfile instructions that build the "skeleton" of an image: FROM, WORKDIR, COPY, RUN, ARG, ENV, EXPOSE. We are deliberately not mixing startup with this stage yet: first we need to understand how the image is built and what exactly goes into it.

2. Build-time and run-time

One of the most useful habits when working with Docker is to ask yourself every time: "Does this line belong to image building or container startup?" In the Java world we also live like this, we just do not always say it out loud: a Gradle build is one thing, launching a jar is another. Docker works the same way, only everything is in one file.

During docker build, Docker reads the Dockerfile from top to bottom and executes the instructions, creating the result — the image. Many commands really run at this moment: for example, RUN java -version will print the Java version in the build logs while the image is being built. But when you start a container, RUN instructions are not executed again — the container starts from the finished image.

During docker run, Docker takes the image, creates a container, and starts the main process inside it. The container lifecycle is built around this process: while the process is alive, the container is alive; when the process exits, the container exits. At this stage of the course, it is enough to remember one simple thing: Dockerfile should prepare everything needed during build, and the actual startup is better described separately — through ENTRYPOINT/CMD.

3. FROM: choosing the "ground" on which Java will live

FROM is the first line in most Dockerfiles, and it sets the foundation: which base image we are building from. In the context of Java this is especially important, because a Spring Boot service needs a Java runtime environment. You can compare FROM to choosing the operating system and JDK/JRE on a server: before you start an application, you need to decide where it will live at all.

In this course, our base stack is fixed: Java 25 and Spring Boot 4.0.3. That means the base image should contain a Java 25 runtime. In a real starter repository, people usually pin a specific, already verified image line; if your repository already has a base image defined, it is better to stick with it so you do not create a compatibility quest for yourself too early.

Minimal example:

# Dockerfile
# Base image: it already contains Java 25 (runtime) and a base OS
FROM eclipse-temurin:25-jre-jammy

What matters here: FROM does not "connect Java like a library"; it defines the file system and runtime environment. It already has /bin/sh (or something similar), it has java, and it has basic system tools. This is the environment in which your jar will run.

A useful mini-trick for understanding: if you want to make sure the base image really has the required Java version, you can temporarily add RUN (yes, we have not officially reached RUN yet, but we already want to live):

# Base image with Java 25
FROM eclipse-temurin:25-jre-jammy

# Check the Java version during image build
RUN java -version

Then it is enough to look at the output in the docker build logs. The main thing is to remember that this is a build-time check, not part of container startup.

4. WORKDIR: one folder instead of path chaos

If FROM is "which planet we live on", then WORKDIR is "which room our desk is in". This instruction sets the working directory inside the image. Subsequent instructions (COPY, RUN) will be executed relative to it, and the process inside the container usually starts there too — depending on how you launch the application.

For a beginner, WORKDIR is also psychologically useful: you do not need to write long paths in every line, and the image structure becomes clearer right away. The Dockerfile is easier to read. And readability here is not decoration, but a way to avoid later asking: "why is it looking for the file in the wrong place?"

Example:

FROM eclipse-temurin:25-jre-jammy

# Working directory inside the image: the application will live here and start from here
WORKDIR /opt/app

Useful nuance: WORKDIR creates the directory if it does not already exist. So there is no need for a separate "warming up" magic like RUN mkdir -p /opt/app — Docker will do it itself.

In our training service, you can choose any reasonable path (/app, /opt/app, /srv/app). The exact name is not important; the principle is: one clear directory containing the jar and, later, related files.

5. COPY: how your bootJar actually gets into the image

The built bootJar has to be placed inside the image somehow. The answer is COPY. This instruction copies files from the build context into the image. And this is where yesterday's topic shows up: Docker cannot see files "outside the context" and cannot copy ../somewhere — only what was included in the build context.

In our project, the path is usually like this: Gradle puts the jar into build/libs/, so we copy it from there into the working directory inside the image.

Simple version:

FROM eclipse-temurin:25-jre-jammy
WORKDIR /opt/app

# Copy the jar from the build context into the image and fix the name as app.jar
COPY build/libs/*.jar app.jar

There are two important practical details here.

First, *.jar can match more than one file. In a small training project there is usually only one jar, but sometimes a second one appears — for example, if someone built both a "regular jar" and a "bootJar", or if artifacts from previous builds were left behind. Docker may complain or copy something other than what you expected. If that happens, it is better to make the jar path explicit through ARG; for now, just keep this trap in mind.

Second, COPY takes files only from the build context. If your .dockerignore accidentally excludes build/, then COPY build/libs/*.jar ... will not find anything. And that is exactly the kind of case where the error looks like "Docker is acting dumb", while in reality Docker is just honestly doing what you described.

Sometimes a beginner, just to "make sure nothing is forgotten", copies the whole project into the image. The idea looks tempting, but it quickly turns the image into a junk closet. Worse, you stop understanding what exactly got in there. For our training service at this stage, it is enough to copy only the jar (and perhaps later a couple of helper files).

6. RUN: commands executed during build, not at startup

RUN is an instruction that runs a command during image build. It is not a "container startup step", but a "preparation step". Continuing the culinary analogy: RUN is "put the dough in the oven for 10 minutes", but that happens while you are preparing the semi-finished product, not when you reheat the pizza at home later.

In the Java/Spring context, RUN is most often needed when you want to prepare something inside the image: install a system package, create directories, unpack something, perform a check. But at the current stage of the course, we use it very carefully: we do not need to turn a simple Dockerfile into "everything at once".

A demonstrative mini-example that helps you feel the mechanics:

FROM eclipse-temurin:25-jre-jammy
WORKDIR /opt/app

# This command will run during docker build, not during docker run
RUN java -version

If you run docker build, you will see the Java version in the build logs. But if you then start the container, this command will not run again — it has already been executed, and the result is fixed in the image.

And here lives one of the classic beginner mistakes: trying to start the application through RUN:

# ❌ Bad idea (example of a mistake)
# This command will try to start the application during image build
RUN java -jar app.jar

The container does not "start" for a simple reason: RUN will execute during build and either hang (because the application runs forever) or the build will not finish at all. Starting the application is the job of ENTRYPOINT/CMD; we will get to them next.

7. ARG: build-time variables that do not live in the container

ARG is a variable that exists only during build. It is convenient for build parameters that make the Dockerfile a bit more flexible without turning the image into a configuration machine. Important: ARG is not a container environment variable by default. After the build it "disappears", and the application will not see it.

For our project, the clearest use case for ARG is the path to the jar file. We want to copy exactly the jar built by Gradle, but we do not want to hardcode the full artifact name.

Example:

FROM eclipse-temurin:25-jre-jammy
WORKDIR /opt/app

# JAR_FILE is a build parameter: it can be overridden with --build-arg
ARG JAR_FILE=build/libs/*.jar

# Copy exactly what was specified in JAR_FILE
COPY ${JAR_FILE} app.jar

Now, if you ever need to explicitly specify a particular file (for example, if the wildcard matched two jars), you can do it during the build:

# Explicitly specify which jar to copy into the image
docker build \
  --build-arg JAR_FILE=build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar \
  -t docker-java-catalog-service .

Important discipline: ARG is about building, not application behavior. So do not try to use ARG to set the port or profile (that is runtime configuration; its place is in container startup or in ENV, with caveats). Mixing build and startup stages is one of the main sources of "why does it work in one environment but not in another?".

8. ENV: environment variables

If ARG lives only during build, then ENV sets environment variables that will already be available in the running container. This is closer to the familiar development world: you have probably seen SPRING_PROFILES_ACTIVE, SERVER_PORT, and other settings that can be passed through the environment.

In the Spring Boot context this is very useful, because Boot can read configuration from env vars. But our goal now is not to "learn the entire Boot configuration model", but simply to understand the purpose of ENV at the Dockerfile level.

An example that fits our training service well:

FROM eclipse-temurin:25-jre-jammy
WORKDIR /opt/app

ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

# Default values inside the container (can be overridden during docker run)
ENV SERVER_PORT=8080
ENV SPRING_PROFILES_ACTIVE=standalone

Here we set default values so that the container starts predictably even if nothing is passed during docker run. At the same time, remember that ENV can be overridden at container startup, and that is normal:

docker run --rm \
  # Override environment variables for the container startup
  -e SERVER_PORT=9090 \
  -e SPRING_PROFILES_ACTIVE=standalone \
  docker-java-catalog-service

If you want to verify that the variable really exists inside the container, you can even skip running the application and just execute the env command:

# Print environment variables inside the container and filter the one we need
docker run --rm docker-java-catalog-service env | grep SERVER_PORT
# SERVER_PORT=8080

(The output is shown as a comment so it is clear what you should see.)

A small methodological stop sign: environment variables are a powerful tool, but you should not stuff the Dockerfile with them without restraint. If you bake too many values into it, the image becomes locked to one mode, and you lose an important container principle of the course: the same image should be able to live in different modes without rebuilding. Right now we are just getting acquainted with ENV as a tool; we will build a clean configuration model later.

9. EXPOSE: "we expect this port", but it will not go outside by itself

EXPOSE is an instruction that is often either overestimated or completely ignored. In essence it is quite simple: it declares which port the application listens to inside the container. So EXPOSE 8080 is not a command saying "open the port on the computer"; it is more like a label on a box: inside, the service expects 8080.

Why is this useful? Because the image becomes self-documenting, and some tools better understand which port should be considered typical. But Docker will not start publishing the port outside just because of EXPOSE. To open access from the host machine, you still use -p in docker run.

Example:

FROM eclipse-temurin:25-jre-jammy
WORKDIR /opt/app

# ... the jar is already inside the image ...

# Document which port the service listens to inside the container
EXPOSE 8080

And startup will look like this:

# Publish the port externally: host:container
docker run --rm -p 8080:8080 docker-java-catalog-service

A nuance worth keeping in mind: EXPOSE and the application's actual port must match. If you declare EXPOSE 8080 but the application listens on server.port=9090, you get a sign saying "Entrance here" glued to a wall without a door. That is why later today we will also talk about how to pass the port to the application without rebuilding the image, but the EXPOSE instruction itself is exactly about the port "contract" inside the image.

10. Cheat sheet and Dockerfile example

When there are more than two instructions, it is easy to start getting confused about what happens where. The table below is a cheat sheet that helps you quickly restore the picture: which instruction affects build, which affects the container, and how this looks in our Spring Boot project. Do not try to memorize it by heart — instead, learn to check yourself quickly with it.

Summary table

Instruction Phase What it does in simple words What it looks like in our service
FROM
build-time Chooses the base environment (parent image) Java 25 runtime for starting the Boot application
WORKDIR
build-time / affects run-time Sets the working directory inside the image /opt/app as the "home" for app.jar
COPY
build-time Copies files from the build context into the image We place bootJar into app.jar
RUN
build-time Runs a command during build and records the result in the image Checking java -version or preparing files (carefully)
ARG
build-time A variable for build only (does not live in the container) JAR_FILE=build/libs/*.jar
ENV
run-time (saved in the image) An environment variable for the container SERVER_PORT, SPRING_PROFILES_ACTIVE as default values
EXPOSE
metadata Documents the port inside the container EXPOSE 8080 as the expected service port

If after this table you can confidently say: "RUN is build, ENV is startup, EXPOSE is documentation, not port publication", then we have reached the main goal of the lecture: Dockerfile is no longer a random set of lines and has started to read like a clear scenario.

Dockerfile fragment

It is very tempting to immediately write the "perfect Dockerfile", but here it is more useful to move in small steps. For now, let's assemble the top part: base environment, directory, copying the jar, minimal defaults, and port documentation. First we need to see the image skeleton by itself; startup is better kept as a separate block rather than mixed into one lump.

Here is an example of a neat fragment:

# Base environment with Java 25
FROM eclipse-temurin:25-jre-jammy

# Application working directory
WORKDIR /opt/app

# Build parameter: which jar to copy
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

# Default values (can be overridden at startup)
ENV SERVER_PORT=8080
ENV SPRING_PROFILES_ACTIVE=standalone

# Port documentation inside the container (not external publication)
EXPOSE 8080

Even without the startup line, this piece is already useful, because it fixes the agreements: where the application lives, how it gets into the image, what default values are expected, and which port is considered "canonical". After that, the startup part is no longer hanging in the air: it has something to connect to.

11. Typical Dockerfile mistakes

Mistake #1: treating EXPOSE as port publication.
EXPOSE 8080 does not make the service available at localhost:8080. It is only a declaration of the port inside the container. Access from outside appears only through docker run -p hostPort:containerPort, and you have already seen how this works in practice.

Mistake #2: confusing build-time and run-time and trying to start the application through RUN.
When a beginner writes RUN java -jar app.jar, they are effectively asking Docker to start the application during build. But the build must finish, otherwise the image will never appear. Startup is the next lecture (ENTRYPOINT/CMD), and there we will do it correctly.

Mistake #3: trying to use COPY to take files "from outside the project".
Docker copies only from the build context. If you specify a path like COPY ../secret.txt /opt/app/, Docker will not say "well, okay" — it will say "file not found" (or "outside of build context"). This is not a limitation "because Docker is mean", but the basis of reproducibility and build security.

Mistake #4: using ARG as an "application setting".
ARG disappears after build. If you want a value to be available when the container runs, you need either ENV, or passing it through docker run -e ..., or application startup arguments. ARG is suitable for the jar name, build version, and some build switches — but not for the port and profile as runtime behavior.

Mistake #5: copying build/libs/*.jar and then wondering why "the wrong jar" was copied.
A wildcard is convenient, but sometimes dangerous. If multiple jar files appear in build/libs/, Docker may choose something other than what you expected. As soon as you see this behavior, switch to a more explicit choice through ARG JAR_FILE=... so that the build stops being a lottery.

1
Task
Docker for Spring, level 4, lesson 0
Locked
Image skeleton for ping-service
Image skeleton for ping-service
1
Task
Docker for Spring, level 4, lesson 0
Locked
ARG for jar path and ENV for runtime variable
ARG for jar path and ENV for runtime variable
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION