Building Microservices with Spring Boot

Containerizing a Microservice

18 min Lesson 9 of 12

Containerizing a Microservice

Shipping a Spring Boot service as a Docker image gives you a self-contained, reproducible artifact that runs identically on a developer laptop, a CI runner, and a Kubernetes cluster. In this lesson you will learn how to write a production-grade Dockerfile, build and tag the image, run it locally, and manage the configuration and secrets a containerised service needs — without baking credentials into the image layer.

Why Containers for Microservices?

A microservice is only as portable as the environment it depends on. Without containers you need every host to have the correct JDK version, the right application.properties values, and the exact same OS libraries. Containers solve all three problems by bundling the JRE and the fat JAR into a single immutable unit. The key benefits are:

  • Reproducibility: the image built in CI is byte-for-byte what you promote to production.
  • Isolation: each service has its own classpath, port, and environment variables — no shared JVM or port conflicts on the same host.
  • Fast horizontal scaling: an orchestrator (Kubernetes, ECS) can spin up ten more instances of an image in seconds.
  • Immutable deployments: you never patch a running container; you build a new image tag and roll it out.

Writing a Production-Grade Dockerfile

A naive Dockerfile copies the fat JAR and runs it. That works, but it defeats Docker's layer-caching mechanism: every code change rebuilds a 60 MB layer. The standard solution is the multi-layer (or exploded JAR) pattern, which Spring Boot's build plugins support natively.

First, build the exploded-jar layout. With Maven:

mvn package -DskipTests java -Djarmode=layertools -jar target/order-service-0.0.1-SNAPSHOT.jar extract --destination target/extracted

This produces four sub-directories under target/extracted/: dependencies/, spring-boot-loader/, snapshot-dependencies/, and application/. Spring Boot orders them by change frequency: application code changes most often, third-party JARs almost never.

Now write the Dockerfile in the project root:

# ---- Build stage (optional; keeps JDK off the final image) ---- FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /build COPY . . RUN ./mvnw package -DskipTests RUN java -Djarmode=layertools \ -jar target/order-service-0.0.1-SNAPSHOT.jar \ extract --destination target/extracted # ---- Runtime stage ---- FROM eclipse-temurin:21-jre-alpine WORKDIR /app # Create a non-root user to run the service RUN addgroup -S spring && adduser -S spring -G spring USER spring:spring # Copy layers from the build stage in ascending change-frequency order COPY --chown=spring:spring --from=builder /build/target/extracted/dependencies ./ COPY --chown=spring:spring --from=builder /build/target/extracted/spring-boot-loader ./ COPY --chown=spring:spring --from=builder /build/target/extracted/snapshot-dependencies ./ COPY --chown=spring:spring --from=builder /build/target/extracted/application ./ EXPOSE 8081 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Two-stage builds matter for security. The builder stage needs a full JDK (compile + test tools). The runtime stage only needs a JRE. Keeping the JDK out of the final image shrinks the attack surface — there is no javac, no build tools, and no extra binaries that a compromised container process could leverage.

Running as a Non-Root User

By default Docker containers run as root inside the container namespace. If your service has a remote-code-execution vulnerability, an attacker running as root inside the container can — in some configurations — escape to the host. The fix is simple: always create a dedicated system user and switch to it with USER. The --chown flag on each COPY ensures the application files are owned by that user so Spring Boot can read the classpath at runtime.

Building and Tagging the Image

From the project root (where Dockerfile lives):

# Build with a semantic tag docker build -t order-service:1.0.0 . # Also tag it as latest for local convenience docker tag order-service:1.0.0 order-service:latest # Push to a registry (replace with your registry prefix) docker tag order-service:1.0.0 ghcr.io/myorg/order-service:1.0.0 docker push ghcr.io/myorg/order-service:1.0.0
Never use the latest tag in production manifests. latest is mutable — a new push overwrites it and you lose traceability. Tag every release with a version (1.0.0) or the Git commit SHA (git rev-parse --short HEAD), then reference that immutable tag in your Kubernetes or Compose files.

Passing Configuration and Secrets

A container image must be environment-agnostic. Hardcoding spring.datasource.url=jdbc:mysql://prod-db:3306/orders inside the image ties it to a single deployment target and leaks the hostname into the image registry. The correct approach is to inject all environment-specific values at runtime via environment variables, which Spring Boot maps to properties with a well-defined convention:

# Property name Environment variable equivalent # spring.datasource.url SPRING_DATASOURCE_URL # server.port SERVER_PORT # app.jwt.secret APP_JWT_SECRET

Run the container locally with environment variables:

docker run --rm \ -p 8081:8081 \ -e SPRING_DATASOURCE_URL=jdbc:mysql://host.docker.internal:3306/orders \ -e SPRING_DATASOURCE_USERNAME=orders_user \ -e SPRING_DATASOURCE_PASSWORD=secret \ -e SPRING_PROFILES_ACTIVE=docker \ order-service:1.0.0

Use host.docker.internal on macOS/Windows to reach a service running on your host machine. On Linux use the bridge network gateway IP (172.17.0.1 by default) or start a shared Docker network.

Never use -e flags for secrets in CI/CD or production. Environment variables are visible in process listings (ps aux) and in docker inspect output. Use a secrets manager (Kubernetes Secrets with encryption at rest, AWS Secrets Manager, HashiCorp Vault) and mount secrets as files or inject them via a secrets-aware sidecar.

Health Checks

Docker can poll your service's health endpoint and restart unhealthy containers automatically. Spring Boot Actuator exposes /actuator/health out of the box. Add a HEALTHCHECK instruction to your Dockerfile:

HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ CMD wget -qO- http://localhost:8081/actuator/health | grep -q '"status":"UP"' || exit 1

--start-period=40s gives the JVM time to start before Docker starts counting retries. A container that fails three consecutive checks is marked unhealthy and an orchestrator replaces it.

Spring Boot Buildpacks (Alternative to Dockerfile)

Spring Boot 3 ships with built-in Cloud Native Buildpack support. No Dockerfile needed:

./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=order-service:1.0.0

Buildpacks automatically apply a non-root user, layer the JAR, add a memory-calculator JVM agent, and follow best-practice defaults. The trade-off: less control over the exact base image and a slower first build (it downloads the buildpack stack). For teams that want convention over configuration, buildpacks are excellent. For teams with custom base image requirements (corporate CA certificates, hardened OS images), a hand-written Dockerfile gives more control.

JVM Memory Tuning Inside a Container

Without explicit limits the JVM defaults to sizing its heap relative to the host's total RAM, which can be several hundred GBs in a cloud VM. This causes your container to be killed by the OOM-killer when it exceeds its cgroup memory limit. Always set heap bounds explicitly:

ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:InitialRAMPercentage=50.0", "-XX:+UseContainerSupport", "org.springframework.boot.loader.launch.JarLauncher"]

UseContainerSupport (on by default since JDK 10) makes the JVM read the cgroup memory limit instead of the host total. MaxRAMPercentage=75.0 caps the heap at 75% of the container limit, leaving headroom for Metaspace, thread stacks, and native memory.

Summary

Containerising a Spring Boot microservice means writing a multi-stage Dockerfile that separates build tools from the runtime image, exploiting Spring Boot's layer-extraction to maximise Docker cache hits, running as a non-root user, and injecting all environment-specific configuration at runtime rather than baking it in. Add a HEALTHCHECK so orchestrators can detect and replace broken instances automatically. Once your service is an immutable, tagged image you have the artifact you need for the final lesson: wiring two services together into a working system.