How to Containerize a Java Application Securely

Mohammad-Ali A'râbi

TL;DR

  • Containerization or Dockerization is the process of packaging an application and its dependencies into a Docker image.
  • It’s beneficial to containerize your Java application as it provides a consistent environment for development, testing, and deployment.
  • Java is a compiled language, so your Docker image will only contain the compiled Java bytecode and the Java runtime environment.
  • Supply-chain security is the process of checking the dependencies in your application for vulnerabilities.
  • SBOM (Software Bill of Materials) is a list of all the dependencies in your application.
  • It’s a good practice to generate an SBOM when building your Docker image and push it to a registry for later reference.

Technical Requirements

In this article, we will use the following tools:

  • Git: To push the code to a git repository.
  • Docker Desktop: To build and run the Docker image.

Most of the Docker commands are available on Docker Engine as well, but we will use some are only available on Docker Desktop, e.g. docker init. You could dodge that particular command by copying the Dockerfile and other generated files from the GitHub repository. I also assume that you use a Unix-like shell (e.g., bash) to run the commands. So, the commands are compatible with Linux, macOS, and Windows Subsystem for Linux (WSL).

Hello World Java Application

For this article, we will use a toy Spring Boot application. Let’s create a template Spring Boot application using the Spring Initializr. Visit start.spring.io and create a new project with the following settings:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.4.4
  • Packaging: Jar
  • Java: 24

I also named added the following metadata, but you can choose your own:

  • Group: io.dockersecurity
  • Artifact: hello
  • Name: hello
  • Description: Demo project for Docker Security showcase
  • Package name: io.dockersecurity.hello

Click on the “Generate” button to download the project. Then unzip the project and go to the root:

unzip hello.zip 
cd hello

Let’s initialize a git repository here and commit the code:

git init 
git add . 
git commit -m "Initial commit"

Now, let’s push the code to a remote repository on GitHub, to use the CI/CD pipeline later. To do it, you should create a new repository on GitHub and copy the URL of the repository:

git remote add origin <your-repo-url> 
git push -u origin master

My repo URL was git@github.com:DockerSecurity-io/hello.git, so you can access the code here: github.com/DockerSecurity-io/hello.

Let’s Compile

To run the project locally, we have two options:

  • Run the project on the host machine, in which case you need to have Java and Maven installed.
  • Run the project in a Docker container, in which case you only need Docker installed.

I’ll go for the latter, because I don’t have Java 24 and Maven installed on my machine.

Now, to start Dockerizing, let’s execute the following command:

docker init

The interactive wizard will detect that you have Java project. Press Enter to accept the “Java” option, accept the default for source directory and Java version, and enter the port manually:

? What application platform does your project use? Java 
? What's the relative directory (with a leading .) for your app? ./src 
? What version of Java do you want to use? 24 
? What port does your server listen on? 8080

The following files are generated:

  • Dockerfile: The Dockerfile to build the image.
  • compose.yaml: The Docker Compose file to run the image.
  • .dockerignore: The .dockerignore file to exclude files from the build context.
  • README.Docker.md: The README file with instructions on how to build and run the image.

Let’s take a look into the Dockerfile:

# syntax=docker/dockerfile:1
#############################################################################

FROM eclipse-temurin:24-jdk-jammy as deps

WORKDIR /build

COPY --chmod=0755 mvnw mvnw
COPY .mvn/ .mvn/

RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -DskipTests

#############################################################################

FROM deps as package

WORKDIR /build

COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests && \
    mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar

#############################################################################

FROM package as extract

WORKDIR /build

RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted

#############################################################################

FROM eclipse-temurin:24-jre-jammy AS final

ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser

COPY --from=extract build/target/extracted/dependencies/ ./
COPY --from=extract build/target/extracted/spring-boot-loader/ ./
COPY --from=extract build/target/extracted/snapshot-dependencies/ ./
COPY --from=extract build/target/extracted/application/ ./

EXPOSE 8080

ENTRYPOINT [ "java", "org.springframework.boot.loader.launch.JarLauncher" ]

The following base images are used:

  • eclipse-temurin:24-jdk-jammy: The Java Development Kit (JDK) image to compile the Java code, based on Ubuntu 22.04 (Jammy Jellyfish).
  • eclipse-temurin:24-jre-jammy: The Java Runtime Environment (JRE) image to run the compiled Java code, based on Ubuntu 22.04 (Jammy Jellyfish).

At the time of writing this article, Java 24 was recently released, so the Eclipse Temurin images are not available yet. To address that, we will use the following images instead:

  • sapmachine:24-jdk-ubuntu-noble: The JDK image based on Ubuntu 24.04 (Noble Numbat).
  • sapmachine:24-jre-ubuntu-noble: The JRE image based on Ubuntu 24.04 (Noble Numbat).

These images are provided by SAP, the German vendor of ERP systems, who also provides a free and open-source distribution of the OpenJDK. You can find their Java images on Docker Hub: hub.docker.com/_/sapmachine.

After replacing the base images in the Dockerfile, you can execute the following command to build and run the image:

docker compose up

This command will use the Docker Compose configuration that looks like this:

services:
  server:
    build:
      context: .
    ports:
      - 8080:8080

The server service will build the image using the Dockerfile in the current directory and expose the port 8080 on the host machine.

The application starts successfully, but also directly stops with exit code 0. This means that the application is running, but there is no endpoint to access it. Let’s add a simple endpoint to the application.

Note. Don’t forget to commit everything and push before proceeding.

Add a Controller

Let’s add a simple controller to the application. Create a new file src/main/java/io/dockersecurity/hello/HelloController.java with the following content:

package io.dockersecurity.hello;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/")
    public String hello() {
        return "Hello, Docker Security!";
    }
}

Also, add the following block to your pom.xml to include the Spring Web dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Now, let’s build the project and run it again:

docker compose up --build

The application should start successfully and you can access it at localhost:8080. Let’s check the endpoint:

curl http://localhost:8080

It should say “Hello, Docker Security!”. Voilà!

Extract the SBOM

SBOM, or Software Bill of Materials, is a list of all the dependencies in your application. It’s a good practice to generate an SBOM when building your Docker image and push it to a registry for later reference.

The image that is built during the docker compose up command is tagged automatically as hello-server. Let’s create an SBOM for it with the following command:

docker sbom hello-server

This command lists all the dependencies and shows them in the terminal in a human-readable format. To create a proper SBOM file, let’s use SPDX, a standard for SBOMs:

docker sbom hello-server --format spdx > sbom.json

This command creates an SBOM in the SPDX (Software Package Data Exchange) format and saves it to the sbom.json file.

It would be great to push this file to a registry for later reference. To do that, we can use attestations!

Attestations

When building a Docker image, you can add attestations to it. Attestations are metadata that can be used to verify the integrity of the image. The attestation can be uploaded into the registry along with the image, and later used to verify the image.

To add an SBOM attestation to the image, we can use the following command:

docker buildx build --tag <namespace>/<image>:<version> \   
--attest type=sbom --push .

This command builds the image and adds an SBOM attestation to it. The image is then pushed to the registry. I used the following tag and pushed my image: aerabi/spring-hello:latest.

Note. To use this command, you should turn on containerd image store on your Docker Desktop. You can do it in the Docker Desktop settings.

To verify the SBOM attestation, you can use the following command:

docker buildx imagetools inspect <namespace>/<image>:<version> 
--format "{{ json .SBOM.SPDX }}"

You can check this command with the image I pushed:

docker buildx imagetools inspect aerabi/spring-hello:latest \  
--format "{{ json .SBOM.SPDX }}"

As Java is a compiled language, we used different images for building and running the application. This is called “multi-stage build”. A vulnerability in the build image could also affect the runtime image, so it’s important to check dependencies in all stages of the build process.

To let BuildKit check the dependencies in all the stages, you can add the following ARG command under each FROM command in the Dockerfile:

FROM sapmachine:24-jdk-ubuntu-noble as deps
ARG BUILDKIT_SBOM_SCAN_STAGE=true

FROM deps as package
ARG BUILDKIT_SBOM_SCAN_STAGE=true

FROM package as extract
ARG BUILDKIT_SBOM_SCAN_STAGE=true

Build the image again with SBOM attestations and push it to the registry:

docker buildx build --tag <namespace>/<image>:<version> 
--sbom=true --push .

Now, let’s check the SBOM attestation for the image:

docker buildx imagetools inspect <namespace>/<image>:<version> \  
--format "{{ json .SBOM.SPDX }}" >> sbom.multi.json

You can compare the SBOM with the one we stored initially to see if there are any differences. The one extracted from the multi-stage build should be more comprehensive.

Docker Scout

Docker Scout is a tool that can be used to check for vulnerabilities in Docker images. It is shipped together with Docker Desktop. Let’s check the vulnerabilities in the image we built:

docker scout cves <namespace>/<image>:<version>

For my image, it shows the following output:

SBOM obtained from attestation, 578 packages found 
✓ Provenance obtained from attestation 
✓ No vulnerable package detected

This means that the image is free from vulnerabilities. Great job! Also, the SBOM attestation is obtained from the image, so no further scan was needed to get the list of dependencies.

Conclusion

In this article, we learned how to containerize a Java application securely. We used a Spring Boot application as an example and built a Docker image for it. We also generated an SBOM for the image and pushed it to a registry with an attestation. Finally, we checked for vulnerabilities in the image using Docker Scout.

It’s also important to note that the build process should be automated and integrated into the CI/CD pipeline. The GitHub repository for the project is available at github.com/DockerSecurity-io/hello and contains a GitHub Actions workflow for building and pushing the image to the registry.

I hope this article was helpful and you learned something new.

Total
0
Shares
Previous Post

01-2025 | 30 Years Of JAVA – Special Edition

Next Post

Azul Introduces 100 – 1000x More Accurate In-Production Java Vulnerability Detection

Related Posts

Securing the Future of AI: Authorization for Java RAG Systems using LangChain4j and OpenFGA

In this post, we explore how to build a robust Java-based RAG system by integrating LangChain4j with OpenFGA for fine-grained, relationship-based access control. Learn how to tackle the unique security challenges of RAG applications—from dynamic context and complex document relationships to real-time authorization checks—and follow step-by-step examples that show you how to implement a secure system.
Deepu Sasidharan
Read More