Dockerizing Spring Boot Applications
Package and run Spring Boot applications in Docker — writing Dockerfiles, multi-stage builds, Docker Compose for local development, and best practices.
Containerising a Spring Boot app makes it portable across environments — the same image runs identically on a developer’s laptop, in CI, and in production. Docker eliminates “works on my machine” problems by bundling the application and everything it needs to run into a single, self-contained unit.
Prerequisites
- Docker Desktop installed and running
- A built Spring Boot project
Simple Dockerfile
The simplest Dockerfile copies the built JAR into a JRE image and runs it. This is fine for getting started, but has a drawback: every rebuild copies the entire JAR, even if you only changed one line of application code. The dependencies — the large part of the JAR — get re-sent to the Docker daemon every time.
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Build and run:
./mvnw clean package -DskipTests
docker build -t my-app:latest .
docker run -p 8080:8080 my-app:latest
Multi-Stage Build (Recommended)
A multi-stage build separates the build environment from the runtime image. The first stage uses a full JDK image to compile and package. The second stage uses a minimal JRE image and copies only the compiled output. The final image has no Maven, no JDK, no source code — just what’s needed to run.
Spring Boot’s layered JAR mode goes further by splitting the JAR into layers ordered by how often they change. Docker caches each layer independently, so if you only change application code, only the last (smallest) layer is rebuilt and pushed.
# Stage 1: Build — uses full JDK + Maven to compile the project
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build
# Copy dependency descriptor first — Docker caches this layer until pom.xml changes
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN ./mvnw dependency:go-offline -q # pre-download all dependencies into the cache layer
# Copy source and build — this layer only rebuilds when source changes
COPY src ./src
RUN ./mvnw clean package -DskipTests -q
# Extract layered JAR — splits into: dependencies / spring-boot-loader / snapshot-deps / application
RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted
# Stage 2: Runtime — minimal JRE image, no build tools
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# Run as a non-root user — important for container security
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Copy layers in order of change frequency: rarely-changing layers first
# Docker can reuse cached layers for dependencies even when application code changes
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./ # changes most often — last layer
USER appuser
EXPOSE 8080
# Exec form ensures the JVM receives OS signals (SIGTERM) for graceful shutdown
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
The layered approach means: if you only changed application code, Docker rebuilds only the last layer. The large dependencies layer — which rarely changes — stays cached, making builds and pushes much faster.
.dockerignore
Exclude files that don’t belong in the build context. Without this, Docker sends your entire project directory (including target/, .git/, IDE files) to the daemon on every build, which is slow and wastes bandwidth.
target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
JVM Tuning for Containers
Without JVM flags, the JVM reads the host machine’s memory limits and may allocate far more heap than the container is allowed to use. -XX:+UseContainerSupport tells the JVM to respect cgroup memory limits. -XX:MaxRAMPercentage=75.0 caps heap at 75% of the container’s memory, leaving room for off-heap memory and the OS.
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+ExitOnOutOfMemoryError", \
"org.springframework.boot.loader.launch.JarLauncher"]
Or pass JVM flags at runtime without rebuilding the image:
docker run -p 8080:8080 \
-e JAVA_OPTS="-XX:MaxRAMPercentage=75.0" \
my-app:latest
Environment Variables and Secrets
Never hardcode credentials in a Dockerfile or image. Pass them at runtime so the same image works across all environments and secrets don’t end up in the image layer history.
docker run -p 8080:8080 \
-e SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/mydb \
-e DB_PASSWORD=secret \
-e JWT_SECRET=my-secret-key \
my-app:latest
Spring Boot’s relaxed binding maps SPRING_DATASOURCE_URL → spring.datasource.url automatically, so environment variable names follow the same pattern as property names.
Docker Compose — Local Development
Docker Compose lets you define and run a multi-container setup with a single command. It’s the standard way to run your app alongside its database and any other services during development, without installing them locally. The depends_on with health check ensures the database is actually ready before the app starts.
# docker-compose.yml
version: '3.9'
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/demodb
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
SPRING_PROFILES_ACTIVE: docker
depends_on:
db:
condition: service_healthy # wait for the health check to pass before starting the app
db:
image: mysql:8.3
environment:
MYSQL_DATABASE: demodb
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql # persist data across container restarts
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql-data: # named volume — survives docker compose down
.env file (not committed to git — add to .gitignore):
DB_PASSWORD=devpassword123
docker compose up --build # build images and start all services
docker compose up -d # start in background (detached mode)
docker compose logs -f app # tail the app's log output
docker compose down # stop and remove containers (data is preserved in volumes)
docker compose down -v # also remove volumes — wipes the database
application-docker.properties
Create a Docker-specific Spring profile for settings that differ when running in a container. The hostname db resolves to the database container via Docker’s internal DNS.
# src/main/resources/application-docker.properties
spring.datasource.url=jdbc:mysql://db:3306/demodb # 'db' is the service name in docker-compose.yml
spring.jpa.hibernate.ddl-auto=update
logging.level.com.example=INFO
Buildpacks — Zero-Config Image Build
Spring Boot can build an OCI image without a Dockerfile using Cloud Native Buildpacks. It automatically detects the runtime and applies security hardening. The tradeoff is speed — buildpacks are slower and less transparent than a hand-written Dockerfile.
./mvnw spring-boot:build-image -DskipTests
# Run the generated image
docker run -p 8080:8080 my-app:0.0.1-SNAPSHOT
Configure the image name in pom.xml:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>myregistry/my-app:${project.version}</name>
</image>
</configuration>
</plugin>
Publishing to a Registry
Once your image is built, push it to a registry so it can be pulled in CI and production:
# Tag for Docker Hub
docker tag my-app:latest yourusername/my-app:1.0.0
# Login and push to Docker Hub
docker login
docker push yourusername/my-app:1.0.0
# Or for GitHub Container Registry
docker tag my-app:latest ghcr.io/yourusername/my-app:1.0.0
echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin
docker push ghcr.io/yourusername/my-app:1.0.0
Useful Docker Commands
docker ps # list running containers
docker images # list all local images
docker stop <container-id> # gracefully stop a container
docker system prune # remove stopped containers and dangling images
docker exec -it <container-id> /bin/sh # open a shell inside a running container (for debugging)
docker logs -f <container-id> # stream container logs
docker stats # real-time CPU and memory usage per container