🐳 Ship It: Containerization with Docker and Docker Compose

The gap between "works on my machine" and "runs in production" is a Dockerfile. This article covers the DOCK framework — multi-stage builds that produce minimal images, non-root container security, Docker Compose orchestration with health checks, and lean image optimization.

Docker containerization with Docker Compose — Spring Boot microservice packaging
📚 Part of the series: Spring Boot Microservices

📚 Series Navigation:
Previous: Part 11 - Trust, But Verify
👉 You are here: Part 12 - Ship It
Next: Part 13 - To Production and Beyond →


📋 Introduction

You've written the code. You've tested it. It runs perfectly on your machine. Now comes the question that has haunted developers since the dawn of distributed systems: How do you get it to run on someone else's machine?

In the pre-Docker era, the answer was a 47-step deployment guide that started with "Install Java 25" and ended three days later with "Pray." You'd spend more time configuring the server than writing the application. "Works on my machine" was the official motto of deployment.

Docker changed everything. Instead of shipping instructions, you ship the entire runtime environment. Your application, its dependencies, its JVM, its configuration — all wrapped in a single, immutable image that runs identically everywhere. Your laptop, your colleague's laptop, the CI server, staging, production — same image, same behavior.

But "just Dockerize it" is like saying "just cook dinner." The result depends entirely on how you do it. A naive Dockerfile produces a 1GB image with the JDK, build tools, and source code baked in. A production-ready Dockerfile produces a 200MB image with just the JRE, your JAR, and a non-root user.

In this article, we'll build a production-grade Docker setup for the Weather Microservice. Multi-stage builds that keep images lean, non-root users that keep containers secure, health checks that keep orchestrators informed, and Docker Compose that ties everything together with a single command.

Time to put your code in a box and ship it. ☕


🐳 The DOCK Framework

Our containerization strategy follows the DOCK framework:

Letter Principle Description
D Distro-minimal Use the smallest base image that works — Alpine JRE, not full JDK
O Optimized Builds Multi-stage builds separate compilation from runtime, keeping images lean
C Compose Orchestration Multi-container applications managed with Docker Compose
K Kept Secure Non-root users, read-only filesystems, no unnecessary packages

🔥 Critical Insight: Every megabyte in your Docker image is a megabyte that needs to be pulled on every deploy, stored on every node, and scanned for every vulnerability. Smaller images deploy faster, cost less, and have fewer attack surfaces.


🔬 The Dockerfile: Multi-Stage Build

Here's the entire Dockerfile for the Weather Microservice:

# Multi-stage build for optimal image size
FROM maven:3.9-eclipse-temurin-25 AS build

WORKDIR /app

# Copy pom.xml and configuration files
COPY pom.xml .
COPY checkstyle-suppressions.xml .
COPY maven-version-rules.xml .
RUN mvn dependency:go-offline -B

# Copy source code and build
COPY src ./src
RUN mvn clean package -DskipTests

# Production stage
FROM eclipse-temurin:25-jre-alpine

# Application port (can be overridden at build time)
ARG APP_PORT=8080

WORKDIR /app

# Create non-root user for security
RUN addgroup -S spring && adduser -S spring -G spring

# Create logs and data directories with proper ownership
RUN mkdir -p /app/logs /data && chown -R spring:spring /app/logs /data

# Copy jar from build stage
COPY --from=build /app/target/*.jar app.jar

# Switch to non-root user
USER spring:spring

# Expose application port
EXPOSE ${APP_PORT}

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
  CMD wget -q -O - http://localhost:${APP_PORT}/actuator/health || exit 1

# Run the application
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

This is a dense 45 lines, and every line has a reason. Let's break it down stage by stage.


Stage 1: The Build Stage

FROM maven:3.9-eclipse-temurin-25 AS build

WORKDIR /app

# Copy pom.xml and configuration files
COPY pom.xml .
COPY checkstyle-suppressions.xml .
COPY maven-version-rules.xml .
RUN mvn dependency:go-offline -B

Why multi-stage? A single-stage build would include Maven, the JDK, all source code, and all downloaded dependencies in the final image. That's easily 800MB+ of unnecessary baggage. Multi-stage builds use one image to build and a different, smaller image to run.

The dependency caching trick: Notice we copy pom.xml before the source code, then run mvn dependency:go-offline. Docker caches each layer. If only your source code changes (not your dependencies), Docker reuses the cached dependency layer. This means rebuilds go from "3 minutes downloading the internet" to "15 seconds compiling your code."

The order matters:

  1. Copy pom.xml → Changes rarely
  2. Download dependencies → Cached until pom.xml changes
  3. Copy source code → Changes frequently
  4. Compile → Only recompiles, doesn't re-download

-B (batch mode) — Suppresses Maven's progress bar output. In a Docker build, nobody's watching the progress bar, and the output clutters the build log.

# Copy source code and build
COPY src ./src
RUN mvn clean package -DskipTests

-DskipTests — Tests should run in CI, not in the Docker build. Running tests during the Docker build doubles the build time and requires test infrastructure (databases, mock servers) that doesn't exist in the build environment.

After this stage, we have a JAR file at /app/target/*.jar. The build stage — Maven, JDK, source code, and all — gets thrown away. Only the JAR moves to the next stage.


Stage 2: The Production Stage

FROM eclipse-temurin:25-jre-alpine

eclipse-temurin:25-jre-alpine — Every word in that tag matters:

Choice Alternative Why
eclipse-temurin openjdk Temurin is the successor to AdoptOpenJDK. Well-maintained, free, production-ready.
25 21, 17 Java 25 — matching our project's JDK version for virtual threads support
-jre (full JDK) JRE is ~70MB smaller. You don't need javac in production.
-alpine -debian, -ubuntu Alpine Linux is ~5MB. Debian is ~120MB. Less OS = less attack surface + faster pulls.

The result: a base image around 100MB instead of 400MB+.

Creating the Non-Root User

# Create non-root user for security
RUN addgroup -S spring && adduser -S spring -G spring

# Create logs and data directories with proper ownership
RUN mkdir -p /app/logs /data && chown -R spring:spring /app/logs /data

Why non-root? By default, Docker containers run as root. If an attacker compromises your application, they get root access to the container — and potentially to the host if there's a container escape vulnerability. Running as a non-root user limits the blast radius of a compromise.

-S (system user) — Creates a system user without a home directory or login shell. This user exists solely to run the application.

Directory ownership — We create /app/logs and /data before switching to the non-root user, then chown them to the spring user. Without this, the application would crash trying to write logs or database files.

Copying the Artifact

COPY --from=build /app/target/*.jar app.jar

--from=build — This is the magic of multi-stage builds. We reach back into the build stage and grab just the JAR file. Everything else — Maven, JDK, source code, downloaded dependencies — stays in the build stage and doesn't make it into the final image.

app.jar — We rename the JAR to a predictable name. Spring Boot's default naming includes the version (weather-service-1.0.0.jar), which changes with every release. A fixed name simplifies the ENTRYPOINT.

Switching to Non-Root

USER spring:spring

From this point on, every command — including the ENTRYPOINT — runs as the spring user. This is placed after all file operations that need root permissions (creating users, creating directories, copying files).

Port Exposure

ARG APP_PORT=8080
EXPOSE ${APP_PORT}

ARG APP_PORT=8080 — Build argument with a default. You can override it: docker build --build-arg APP_PORT=9090. The EXPOSE instruction is documentation — it tells users which port the container listens on, but doesn't actually publish it. You still need -p 8080:8080 at runtime.

Health Check

HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
  CMD wget -q -O - http://localhost:${APP_PORT}/actuator/health || exit 1

The Docker health check lets the container report its own health status. Docker (and Docker Compose) use this to determine if the container is ready to receive traffic.

Parameter Value Why
--interval 30s Check every 30 seconds — frequent enough to detect issues, not so frequent it's wasteful
--timeout 3s If the health endpoint doesn't respond in 3 seconds, something's wrong
--start-period 60s Give the JVM 60 seconds to start before checking health. Spring Boot + JPA + Flyway migrations can take time
--retries 3 Three consecutive failures before marking unhealthy — avoids false positives from network blips

Why wget instead of curl? Alpine doesn't include curl by default, but it does include wget. Installing curl would add unnecessary size to the image. The -q -O - flags make wget quiet and output to stdout (which we ignore).

The Entrypoint

ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

Exec form ["java", ...] — The exec form (JSON array) runs java directly as PID 1. The shell form (java -jar app.jar) runs through /bin/sh -c, which means java is a child process and doesn't receive signals properly. With exec form, SIGTERM (from docker stop) goes directly to the JVM, enabling graceful shutdown.

-Djava.security.egd=file:/dev/./urandom — Java's SecureRandom sometimes blocks on /dev/random when the entropy pool is low (common in containers). This redirects it to /dev/urandom, which never blocks. The ./ in the path is a known workaround for a Java bug that ignores the setting without it.

Pro tip: Use ENTRYPOINT for the main command and CMD for default arguments that users might override. In our case, the Java command is always the same, so ENTRYPOINT alone is sufficient.


🎯 Docker Compose: Multi-Container Orchestration

A weather service doesn't run alone. It needs a database (H2 in our case, but could be PostgreSQL), a tracing backend (Zipkin), and potentially other supporting services. Docker Compose lets you define and run all of them with a single command.

# docker-compose.yml
services:
  weather-service:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - APP_PORT=${APP_PORT:-8080}
    container_name: weather-microservice
    ports:
      - "${APP_PORT:-8080}:${APP_PORT:-8080}"
    environment:
      - SPRING_PROFILES_ACTIVE=dev
      - WEATHER_API_KEY=${WEATHER_API_KEY}
      - SERVER_PORT=${APP_PORT:-8080}
      - DATABASE_URL=jdbc:h2:file:/data/weatherdb;AUTO_SERVER=FALSE
      - DATABASE_USERNAME=sa
      - DATABASE_PASSWORD=
      - TRACING_SAMPLE_RATE=${TRACING_SAMPLE_RATE:-1.0}
      - ZIPKIN_URL=http://zipkin:9411/api/v2/spans
    volumes:
      - weather-data:/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:${APP_PORT:-8080}/actuator/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 60s
    networks:
      - weather-network
    depends_on:
      zipkin:
        condition: service_healthy

  zipkin:
    image: openzipkin/zipkin:latest
    container_name: zipkin
    ports:
      - "9411:9411"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9411/health"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 10s
    networks:
      - weather-network

volumes:
  weather-data:
    driver: local

networks:
  weather-network:
    driver: bridge

Each section has interesting design choices worth exploring.

Service: weather-service

Build Configuration

build:
  context: .
  dockerfile: Dockerfile
  args:
    - APP_PORT=${APP_PORT:-8080}

context: . — The build context is the current directory. Docker sends everything in this directory to the daemon (respecting .dockerignore). Keep your context small to speed up builds.

args — Build arguments are injected into the Dockerfile's ARG instructions. ${APP_PORT:-8080} uses the shell variable APP_PORT if set, otherwise defaults to 8080.

Environment Variables

environment:
  - SPRING_PROFILES_ACTIVE=dev
  - WEATHER_API_KEY=${WEATHER_API_KEY}
  - DATABASE_URL=jdbc:h2:file:/data/weatherdb;AUTO_SERVER=FALSE
  - ZIPKIN_URL=http://zipkin:9411/api/v2/spans

Spring Boot automatically maps environment variables to properties: WEATHER_API_KEY becomes weather.api.key, DATABASE_URL maps to database.url. This is the twelve-factor app approach — configuration through the environment.

WEATHER_API_KEY=${WEATHER_API_KEY} — References an environment variable from the host. You set it before running Compose: export WEATHER_API_KEY=your-key-here. This keeps secrets out of the docker-compose.yml file (which gets committed to git).

ZIPKIN_URL=http://zipkin:9411/api/v2/spans — Uses Docker's internal DNS. Within the weather-network, containers can resolve each other by service name. The weather service reaches Zipkin at http://zipkin:9411, not http://localhost:9411.

AUTO_SERVER=FALSE — H2's auto-server mode allows multiple processes to share a database file. In a container, we're the only process — disabling it avoids potential port conflicts.

Volumes

volumes:
  - weather-data:/data

Named volumes persist data across container restarts. The H2 database file lives at /data/weatherdb, so even if you docker compose down && docker compose up, your data survives.

Without this volume, every container restart would start with an empty database. Flyway would re-run migrations, but all your location and weather data would be gone.

Restart Policy

restart: unless-stopped

Four options:

  • no — Don't restart (default)
  • on-failure — Restart only if the container exits with non-zero status
  • always — Always restart, even after docker stop
  • unless-stopped — Always restart, except when explicitly stopped

unless-stopped is the sweet spot for development. If the application crashes, Docker restarts it automatically. If you deliberately stop it with docker compose stop, it stays stopped.

Dependency Ordering

depends_on:
  zipkin:
    condition: service_healthy

This is crucial. Without condition: service_healthy, depends_on only waits for the container to start — not for the service to be ready. Zipkin might take 10 seconds to start accepting trace data. Without the health condition, the weather service would start, try to send traces to Zipkin, fail, and log errors for the first 10 seconds.

With service_healthy, Docker Compose waits for Zipkin's health check to pass before starting the weather service. Clean startup, no transient errors.

Service: zipkin

zipkin:
  image: openzipkin/zipkin:latest
  container_name: zipkin
  ports:
    - "9411:9411"
  healthcheck:
    test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9411/health"]
    interval: 10s
    timeout: 3s
    retries: 3
    start_period: 10s

Zipkin runs as a pre-built image — no build needed. The health check interval is 10 seconds (more frequent than the weather service) because Zipkin starts faster and we want to unblock the weather service quickly.

Port 9411 is published so you can access the Zipkin UI at http://localhost:9411 in your browser. This gives you the distributed tracing dashboard we discussed in Part 10.

Networks

networks:
  weather-network:
    driver: bridge

A bridge network isolates the services from other Docker containers on the host. Within the network, services can communicate by name (http://zipkin:9411). Outside the network, only published ports are accessible.

This is security by default. Even if you have other Docker containers running on the same machine, they can't reach the weather service or Zipkin unless you explicitly connect them to this network.


⚡ Running It All

Development Workflow

# Start everything
docker compose up -d

# Watch the logs
docker compose logs -f weather-service

# Check health status
docker compose ps

# Stop everything (keep data)
docker compose stop

# Stop and remove containers (keep volumes)
docker compose down

# Stop, remove containers AND volumes (reset everything)
docker compose down -v

Useful Commands

Command Purpose
docker compose up --build Rebuild image before starting (after code changes)
docker compose logs -f Follow logs from all services
docker compose exec weather-service sh Shell into running container
docker compose ps Show container status and health
docker inspect weather-microservice Detailed container info
docker stats Live resource usage (CPU, memory)

The .env File

Instead of exporting variables, use a .env file in the same directory as docker-compose.yml:

WEATHER_API_KEY=your-api-key-here
APP_PORT=8080
TRACING_SAMPLE_RATE=1.0

Docker Compose automatically reads .env and substitutes the variables. Add .env to your .gitignore to keep secrets out of version control.


🔒 Security Best Practices

1. Non-Root User

We covered this in the Dockerfile section, but it's worth emphasizing. The container runs as spring:spring, not root. This is the most impactful security measure for containers.

2. No Shell in Production

Alpine-based images include sh but not bash. If you wanted to go further, you could use a distroless base image that has no shell at all:

# Even more minimal (no shell, no package manager)
FROM gcr.io/distroless/java21-debian12

No shell means an attacker who gains code execution can't spawn a reverse shell. The tradeoff: you can't exec into the container for debugging.

3. Read-Only Root Filesystem

In Kubernetes (coming in Part 13), we set readOnlyRootFilesystem: true. In Docker, you can achieve the same:

docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/logs \
  -v weather-data:/data \
  weather-service

The application can only write to explicitly mounted volumes and tmpfs mounts. If an attacker tries to write a web shell to the filesystem, it fails.

4. Minimal Packages

Alpine Linux includes minimal packages. We don't install anything extra (no curl, no vim, no netcat). Every installed package is a potential attack vector.


📊 Image Size Optimization

Let's compare image sizes:

Approach Approximate Size
FROM maven:3.9-eclipse-temurin-25 (single stage) ~800MB
FROM eclipse-temurin:25-jdk (JDK, no build tools) ~450MB
FROM eclipse-temurin:25-jre (JRE only) ~300MB
FROM eclipse-temurin:25-jre-alpine (Alpine JRE) ~200MB

Our multi-stage build with Alpine JRE produces an image around 200MB. That's 4x smaller than a naive single-stage build. It pulls 4x faster, stores 4x cheaper, and has 4x fewer packages to scan for vulnerabilities.

Docker Layer Caching Strategy

Each Dockerfile instruction creates a layer. Layers are cached and reused if the instruction and all preceding layers haven't changed. Our Dockerfile is structured for maximum cache efficiency:

Layer 1: Base image (changes: almost never)
Layer 2: Non-root user creation (changes: never)
Layer 3: Directory creation (changes: never)
Layer 4: JAR file copy (changes: every build)
Layer 5: USER switch (changes: never)

Layers 1-3 are cached across all builds. Only Layer 4 (the JAR copy) changes when you modify code. This means rebuilds are fast — Docker only rebuilds the layers that changed.

Pro tip: Put instructions that change frequently (like COPY) at the end of the Dockerfile. Put instructions that change rarely (like RUN apt-get install) at the beginning. This maximizes layer cache hits.


📝 The Docker Checklist

Dockerfile

  • [ ] Multi-stage build (build stage + runtime stage)
  • [ ] Dependency caching (copy pom.xml before src)
  • [ ] Minimal base image (JRE + Alpine, not JDK + Debian)
  • [ ] Non-root user created and activated
  • [ ] Application directories owned by non-root user
  • [ ] Health check defined
  • [ ] ENTRYPOINT in exec form (JSON array)
  • [ ] No secrets in the image
  • [ ] .dockerignore excludes unnecessary files

Docker Compose

  • [ ] Environment variables for configuration (twelve-factor)
  • [ ] Secrets via host environment or .env file (not hardcoded)
  • [ ] Named volumes for persistent data
  • [ ] Health checks on all services
  • [ ] depends_on with condition: service_healthy
  • [ ] Custom bridge network for service isolation
  • [ ] restart: unless-stopped for resilience
  • [ ] Port mapping only for services that need external access

Security

  • [ ] Running as non-root user
  • [ ] Minimal base image (fewer packages = fewer vulnerabilities)
  • [ ] No build tools in production image
  • [ ] Secrets not baked into the image
  • [ ] .env file in .gitignore

🎓 Conclusion: From Code to Container

That 47-step deployment guide from the intro? Replace it with a Dockerfile and a docker compose up. Here's what makes that possible:

  1. Multi-stage builds are the foundation — Build in one stage, run in another. Your production image should never contain build tools, source code, or test dependencies.

  2. Order Dockerfile instructions by change frequency — Static layers first (base image, user creation), dynamic layers last (COPY jar). This maximizes Docker layer cache hits.

  3. The dependency caching trick saves minutes — Copy pom.xml before src, run dependency download, then copy source code. Dependencies are cached until pom.xml changes.

  4. Alpine JRE is the sweet spot — Small (~200MB), secure (minimal packages), and has everything Java needs. Don't use the full JDK in production.

  5. Non-root users limit blast radius — If your application is compromised, the attacker only has spring user permissions, not root.

  6. Health checks enable orchestration — Docker, Docker Compose, and Kubernetes all use health checks to decide if a container is ready.
    Define them in the Dockerfile and Compose file.

  7. Exec form ENTRYPOINT enables graceful shutdown — JSON array format sends signals directly to the JVM, allowing Spring Boot's graceful shutdown to complete in-flight requests.

  8. Docker Compose manages the full stack — One command starts the weather service and Zipkin with proper networking, health ordering, and persistent storage.

  9. Environment variables externalize configuration — Follow the twelve-factor app approach. No hardcoded URLs, credentials, or environment-specific settings in the image.

  10. .env + .gitignore = safe secrets — Keep API keys in .env, keep .env out of version control. Simple, effective.

Coming Next Week:
Part 13: To Production and Beyond - Kubernetes Deployment with Helm ☸️


📚 Series Progress

✅ Part 1: The Blueprint Before the Build
✅ Part 2: Spring Boot Alchemy
✅ Part 3: REST Assured
✅ Part 4: The Data Foundation
✅ Part 5: When the World Breaks
✅ Part 6: Cache Me If You Can
✅ Part 7: Guarding the Gates
✅ Part 8: Fail Gracefully
✅ Part 9: 10,000 Threads and a Dream
✅ Part 10: Can You See Me Now?
✅ Part 11: Trust, But Verify
✅ Part 12: Ship It ← You just finished this!
⬜ Part 13: To Production and Beyond


Happy shipping, and may your containers always pass their health checks.


Robert Marcel Saveanu

Robert Marcel Saveanu

Software engineer with 15 years in testing, architecture, and the art of surviving corporate dysfunction. Writing about code, quality, and the humans behind both.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Codyssey.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.