07 · Containerization & Deployment¶
A Java service that "works on my machine" needs a repeatable, portable way to ship to production. This module covers packaging a Spring Boot app into a Docker image, running it alongside its database with Compose, and automating the build/test/publish steps with a CI pipeline.
Multi-stage Dockerfile¶
Building inside the final image would drag the entire Maven toolchain and dependency cache into your production image. A multi-stage build compiles in one stage and copies only the resulting jar into a slim runtime stage.
# Dockerfile
# ---- Build stage ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
# Copy dependency descriptor first so Docker can cache the download layer
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
# ---- Runtime stage ----
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Run as a non-root user
RUN addgroup -S app && adduser -S app -G app
USER app
COPY --from=build /app/target/order-service-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The runtime image only contains a JRE (not a full JDK) and the built jar — typically a fraction of the size of the build stage, and with a much smaller attack surface since no build tools ship in production.
Building and running¶
docker build -t order-service:latest .
docker run -p 8080:8080 \
-e DB_PASSWORD='SuperSecret123!' \
order-service:latest
docker-compose.yml — app plus database¶
# docker-compose.yml
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/orders
SPRING_DATASOURCE_USERNAME: orders_app
DB_PASSWORD: ${DB_PASSWORD}
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: orders_app
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "5432:5432"
volumes:
- order_db_data:/var/lib/postgresql/data
volumes:
order_db_data:
The app connects to the database service by its Compose service name (db),
not localhost — Compose puts both containers on a shared network and
resolves service names via DNS.
CI/CD with GitHub Actions¶
A CI pipeline should run the same checks locally available (mvn test) on
every push, and build the deployable artifact once tests pass.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Run tests
run: mvn test -B
build-image:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t order-service:${{ github.sha }} .
- name: Log in to registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Push image
run: |
docker tag order-service:${{ github.sha }} myregistry.example.com/order-service:${{ github.sha }}
docker push myregistry.example.com/order-service:${{ github.sha }}
The build-image job depends on test (needs: test) — a failing test
suite blocks the image from ever being built or pushed. Credentials
(REGISTRY_PASSWORD) come from GitHub Actions secrets, never committed
to the workflow file (see Module 5).
| Step | Purpose |
|---|---|
Multi-stage Dockerfile |
Separates build tooling from the slim runtime image |
.dockerignore |
Keeps the build context small and avoids leaking local files |
docker-compose.yml |
Runs the app and its dependencies together for local dev/testing |
CI test job |
Fails fast before any image is built |
CI build-image job |
Produces a versioned, deployable artifact tied to a commit SHA |
How It Actually Works¶
A Docker container is not a lightweight VM — it's an ordinary Linux
process given the illusion of isolation via namespaces (separate
PID, network, mount, and user views) and resource limits enforced by
cgroups (CPU shares, memory ceilings). This is exactly why a JVM
running inside a container historically miscalculated available memory
(it saw the host's total RAM before container-awareness was added to
the JVM) and why -XX:MaxRAMPercentage and container-aware JVM defaults
(standard since Java 10+) matter: the JVM now reads the cgroup memory
limit, not /proc/meminfo, to size its heap.
Layered images work because each Dockerfile instruction produces an
immutable, content-hashed filesystem layer, and Docker caches and
reuses layers unchanged since the last build — this is the actual
mechanism behind the advice to copy pom.xml/dependencies before
application source in a Dockerfile: dependency-resolution layers stay
cached across builds where only source changed, avoiding a full
re-download every build.
Kubernetes liveness/readiness probes are literal periodic HTTP/TCP checks the kubelet performs against your pod — a failed liveness probe triggers a container restart via the container runtime, while a failed readiness probe only removes the pod from the Service's load-balanced endpoint set without restarting it, a distinction that maps directly onto "is this instance broken" versus "is this instance temporarily not ready for traffic."
Exercise¶
Extend the Dockerfile above with a HEALTHCHECK instruction that curls
/actuator/health every 30 seconds (see Module 8 for
the Actuator endpoint). Then add a second service to the docker-compose.yml
for a Redis cache (image redis:7-alpine) and wire its hostname into the
app's environment as SPRING_CACHE_REDIS_HOST=redis.