📚 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:
- Copy
pom.xml→ Changes rarely - Download dependencies → Cached until
pom.xmlchanges - Copy source code → Changes frequently
- 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 statusalways— Always restart, even afterdocker stopunless-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
- [ ]
.dockerignoreexcludes 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_onwithcondition: service_healthy - [ ] Custom bridge network for service isolation
- [ ]
restart: unless-stoppedfor 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
- [ ]
.envfile 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:
-
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.
-
Order Dockerfile instructions by change frequency — Static layers first (base image, user creation), dynamic layers last (COPY jar). This maximizes Docker layer cache hits.
-
The dependency caching trick saves minutes — Copy
pom.xmlbeforesrc, run dependency download, then copy source code. Dependencies are cached untilpom.xmlchanges. -
Alpine JRE is the sweet spot — Small (~200MB), secure (minimal packages), and has everything Java needs. Don't use the full JDK in production.
-
Non-root users limit blast radius — If your application is compromised, the attacker only has
springuser permissions, not root. -
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. -
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.
-
Docker Compose manages the full stack — One command starts the weather service and Zipkin with proper networking, health ordering, and persistent storage.
-
Environment variables externalize configuration — Follow the twelve-factor app approach. No hardcoded URLs, credentials, or environment-specific settings in the image.
-
.env+.gitignore= safe secrets — Keep API keys in.env, keep.envout 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. ☕