1. Packaging with Docker & Cloud Native Buildpacks
Back in Week 1, ./mvnw clean package produced a single executable JAR —
the exact same artifact that runs locally and in production. Containerizing that JAR
is the next logical step: instead of shipping a file and hoping the target machine has
the right JDK installed, you ship a self-contained image with the JDK, your app, and
everything else it needs baked in.
The fastest path to a working image needs no Dockerfile at all. Spring
Boot's Maven and Gradle plugins integrate Cloud Native Buildpacks — an
industry-standard mechanism (used by Heroku, Google Cloud, and others) that inspects
your project, picks an appropriate JDK and base OS layer, and produces a
production-grade image automatically.
# Requires a running Docker daemon -- Buildpacks builds the image locally
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=codeverse/task-service:latest
# Run it exactly like any other container
docker run -p 8080:8080 codeverse/task-service:latest
No FROM, no RUN apt-get, no base-image chasing — Buildpacks
picks a current JRE, applies security patches to the OS layer, and configures the JVM
sensibly for a container (heap sizing based on the container's memory limit, for
instance) without you writing a line of image-build instructions.
Layered images and why rebuilds are fast
Since Spring Boot 2.3, the executable JAR is internally organized into layers — dependencies, Spring Boot loader classes, snapshot dependencies, and your application classes, each in its own layer, ordered from least-to-most likely to change. Buildpacks (and a layered Dockerfile, covered next) turn each of those into a separate container layer:
1. dependencies ← changes rarely (your third-party libraries)
2. spring-boot-loader ← changes almost never (part of Spring Boot itself)
3. snapshot-dependencies ← changes occasionally (your own multi-module deps)
4. application ← changes on every commit (your compiled classes)
Docker caches layers by content hash. Because your own application code sits in the smallest, topmost layer, a code change only invalidates that one layer — the dependency layers, which are by far the largest, are reused from cache. A rebuild after a one-line code change pushes kilobytes to your registry instead of the full image, which matters directly for how fast the CI/CD pipeline in Section 3 runs on every commit.
When Buildpacks are enough
For the large majority of Spring Boot services — including the Task service you've built through this course — Buildpacks alone are the right choice: no Dockerfile to maintain, automatic security patching of the base image over time, and an image that's already reasonably small and runs as a non-root user by default. Reach for a hand-written Dockerfile only when you need something Buildpacks doesn't give you control over, which is exactly Section 2's topic.
Week 15's capstone asks you to ship a production Spring Boot service end to end — the Buildpacks image, the CI/CD pipeline, and the Kubernetes manifests you build this week are exactly the pieces that project reuses. Getting comfortable with all three now pays off directly there.
2. Writing a Production Dockerfile
Buildpacks cover most services, but sometimes you need more control than they expose:
a non-standard base image mandated by your organization, extra OS packages your app
shells out to (an image-processing binary, a specific CA certificate bundle), a custom
JVM agent baked into the image, or a build environment your CI system already
standardizes on. In those cases, you hand-write a Dockerfile.
Multi-stage builds
A naive Dockerfile that installs Maven, copies source, and builds inside the final image ships the entire build toolchain — compiler, dependency cache, source tree — in your production image, bloating it and widening its attack surface. A multi-stage build fixes this: one stage compiles the app using a full JDK-and-Maven image, and a second, separate stage copies only the built JAR into a minimal JRE-only runtime image. Everything from the build stage — Maven itself, the dependency cache, the source code — is discarded.
# ---- Build stage ----
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /workspace
# Copy only the files needed to resolve dependencies first, so this
# layer is cached across builds until pom.xml actually changes
COPY mvnw pom.xml ./
COPY .mvn/ .mvn/
RUN ./mvnw dependency:go-offline -B
# Now copy the source and build
COPY src/ src/
RUN ./mvnw clean package -DskipTests -B
# ---- Runtime stage ----
FROM eclipse-temurin:21-jre-jammy AS runtime
WORKDIR /app
# Run as a dedicated non-root user, never as root
RUN groupadd --gid 1000 spring && \
useradd --uid 1000 --gid spring --shell /bin/false --create-home spring
USER spring:spring
COPY --from=build --chown=spring:spring /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
The build stage's eclipse-temurin:21-jdk-jammy image is several hundred
megabytes with the full JDK and build tooling; the runtime stage's
21-jre-jammy image carries only the JRE needed to run a compiled JAR — no
compiler, no build tool, no source code. Splitting dependency resolution from the
source copy also means docker build skips re-downloading dependencies on
every code change, since that RUN layer only invalidates when
pom.xml itself changes.
Running as a non-root user
The default user inside most base images is root. If an attacker finds a
way to execute code inside the container, running as root hands them a much larger
blast radius than a dedicated, unprivileged user would. Creating a spring
user with a fixed UID and switching to it with USER spring:spring before
the ENTRYPOINT runs is a small addition with a real security payoff — and
it's exactly the kind of default Buildpacks already applies for you automatically.
.dockerignore
Without one, docker build's build context includes everything in your
project directory — target/, your IDE's metadata, local
.env files, and .git history — slowing the build and
risking secrets leaking into an image layer. Exclude anything that isn't source:
target/
.git/
.idea/
*.iml
.env
.env.local
*.log
Dockerfile
.dockerignore
Default to Buildpacks for day-to-day services. Learn the multi-stage Dockerfile anyway — it's the pattern you reach for the moment a service needs something Buildpacks won't customize, and understanding what a "runtime-only" image actually contains makes debugging container issues in production far less mysterious.
3. CI/CD with GitHub Actions
A container image sitting on your laptop isn't a deployment pipeline. The goal is a
workflow that runs automatically on every push: build the project, run the full test
suite from Week 8 (unit, slice, full-context, and the Testcontainers-backed integration
tests), and — only if everything passes — build and push an image on merges to
main.
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:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
# Testcontainers needs a Docker daemon -- ubuntu-latest runners
# already ship with one, so no extra setup step is required here.
- name: Run full test suite
run: ./mvnw verify -B
- name: Publish test results
if: always()
uses: actions/upload-artifact@v4
with:
name: surefire-reports
path: target/surefire-reports
build-and-push:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push image with Buildpacks
run: |
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=ghcr.io/${{ github.repository }}/task-service:${{ github.sha }}
docker push ghcr.io/${{ github.repository }}/task-service:${{ github.sha }}
The build-and-push job depends on test via
needs: test and additionally guards on the branch and event type — a
broken test run, or a pull request from a branch, never reaches the image-build step.
This is the automated gate that makes "every image in the registry passed the full
Week 8 suite" an actual guarantee instead of a hope.
Secrets handling
The workflow above uses secrets.GITHUB_TOKEN, a token GitHub Actions
generates automatically per run with scoped permissions — enough to push to GitHub
Container Registry (ghcr.io) without you managing credentials at all. For
an external registry (Docker Hub, an AWS ECR repository), you'd instead store a
username and access token as repository secrets under
Settings → Secrets and variables → Actions, then reference them the
same way: ${{ secrets.DOCKERHUB_TOKEN }}. Secrets are encrypted at rest,
masked in logs, and never available to workflows triggered from a fork's pull request
— never hardcode a credential directly into the YAML.
The four test layers you built in Week 8 — fast unit tests, @WebMvcTest slice tests, full-context @SpringBootTest, and Testcontainers integration tests against a real Postgres — are what ./mvnw verify runs here. A regressed endpoint or a broken migration fails the test job and the image is never built, which is the entire point of putting tests before deployment in the pipeline rather than after.
4. Deploying to Kubernetes — the Basics
Kubernetes is a system for running and managing containers across a cluster of machines. Three objects cover what you need for a first real deployment:
A Pod is the smallest deployable unit — one or more containers scheduled together on the same node. A Deployment manages a set of identical Pods for you: it keeps a target number of replicas running, replaces Pods that crash, and handles rolling updates when you push a new image version. A Service gives that shifting set of Pods a single, stable network address, since individual Pods come and go and their IP addresses aren't durable.
A Deployment for the Task service
apiVersion: apps/v1
kind: Deployment
metadata:
name: task-service
labels:
app: task-service
spec:
replicas: 3
selector:
matchLabels:
app: task-service
template:
metadata:
labels:
app: task-service
spec:
containers:
- name: task-service
image: ghcr.io/codeverse/task-service:1.4.2
ports:
- containerPort: 8080
resources:
requests:
cpu: "250m"
memory: "384Mi"
limits:
cpu: "1000m"
memory: "512Mi"
envFrom:
- configMapRef:
name: task-service-config
- secretRef:
name: task-service-secrets
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 15
replicas: 3 tells the Deployment to keep three identical Pods running at
all times, spreading load and surviving the loss of any one Pod or node. The
resources block sets a request (what the scheduler
reserves for this Pod when deciding which node to place it on) and a
limit (a hard ceiling the container is not allowed to exceed).
Wiring the Week 12 health indicators to probes
The readinessProbe and livenessProbe above point directly at
the Actuator health groups you built in Week 12:
/actuator/health/readiness and /actuator/health/liveness.
Kubernetes polls those endpoints on the schedule you configure and acts on the result —
this is precisely why that week wired custom HealthIndicator beans (a
database connectivity check, a downstream Notification-service check) into those
groups: Kubernetes has no idea whether your app is healthy unless your app tells it,
through an endpoint exactly like this one.
A Service to expose it
apiVersion: v1
kind: Service
metadata:
name: task-service
spec:
selector:
app: task-service
ports:
- port: 80
targetPort: 8080
type: ClusterIP
The selector matches the Deployment's Pod labels, so the Service
automatically routes traffic to whichever Pods are currently marked ready —
tying directly back to the readiness probe. A ClusterIP Service is only
reachable from inside the cluster, which is normally correct for an internal API like
the Task service; an Ingress or a LoadBalancer-type Service
would sit in front of it to accept external traffic, a topic for the capstone in Week 15.
Configuration with a ConfigMap and Secret
The Deployment above references a ConfigMap for non-sensitive
configuration and a Secret for credentials, both injected as environment
variables via envFrom:
apiVersion: v1
kind: ConfigMap
metadata:
name: task-service-config
data:
SPRING_PROFILES_ACTIVE: "prod"
MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE: "readinessState,db"
---
apiVersion: v1
kind: Secret
metadata:
name: task-service-secrets
type: Opaque
stringData:
SPRING_DATASOURCE_PASSWORD: "change-me-in-a-real-secret-manager"
Keeping configuration out of the image means the same task-service image
built once in CI can run against different databases and settings in staging versus
production, just by changing which ConfigMap and Secret it's deployed alongside — no
rebuild required. In a real production cluster, secrets are usually sourced from a
dedicated secret manager rather than committed as plain YAML; the Secret
object shown here is the mechanism Kubernetes exposes to your Pods either way.
Every piece of Section 4 depends on Week 12's Actuator work: the health groups being split into readiness and liveness in the first place, and the custom indicators reporting real dependency state, are what make these probes trustworthy instead of just decorative YAML.
5. Hands-on Exercise
Package, pipeline, and deploy the Task service
Take the Task service from Week 12 all the way to a Kubernetes manifest, with an automated pipeline gating the path in between.
Requirements:
- Build a container image for the Task service with
./mvnw spring-boot:build-image, then separately write a multi-stageDockerfileand build it withdocker build. Compare the two images' sizes withdocker imagesand note the wall-clock build time for each on a clean cache. - Write a
.dockerignorefor the hand-written Dockerfile sotarget/, IDE metadata, and any local env files are excluded from the build context. - Write a GitHub Actions workflow at
.github/workflows/ci.ymlwith atestjob that runs./mvnw verify(the full Week 8 suite, including the Testcontainers tests) on every push and pull request, and abuild-and-pushjob that only runs onmainaftertestsucceeds, usingneeds:to enforce the ordering. - Write
task-service-deployment.yamlandtask-service-service.yaml. The Deployment must setreplicas: 3, aresources.requestsandresources.limitsblock, andreadinessProbe/livenessProbepointed at the/actuator/health/readinessand/actuator/health/livenessendpoints from Week 12. - Write a
task-service-config.yamlcontaining aConfigMapwith at leastSPRING_PROFILES_ACTIVE, and reference it from the Deployment viaenvFrom. Validate every manifest withkubectl apply --dry-run=client -f .before considering the exercise done.
No real Kubernetes cluster is required to complete this exercise — kubectl apply --dry-run=client -f . validates the manifests against the Kubernetes API schema without needing a cluster to apply against. If you do have Docker Desktop's built-in Kubernetes or minikube available, applying for real and watching kubectl get pods transition to Running is worth doing once.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What do Cloud Native Buildpacks give you for free compared to hand-writing a Dockerfile?
What do Cloud Native Buildpacks give you for free compared to hand-writing a Dockerfile?
Buildpacks inspect the project and automatically select an appropriate JDK and base OS layer, apply security patches to that base image over time without you tracking CVEs yourself, configure the JVM sensibly for a container (heap sizing based on the container's memory limit, for example), split the image into cache-friendly layers, and run the app as a non-root user by default — all without writing or maintaining a single line of image-build instructions. A hand-written Dockerfile gives you explicit control over every one of those decisions, but you own keeping all of them correct and up to date yourself.
Q2
Why does a multi-stage Dockerfile produce a smaller and safer runtime image than a single-stage build?
Why does a multi-stage Dockerfile produce a smaller and safer runtime image than a single-stage build?
A single-stage build that installs Maven and compiles inside the final image ships the entire build toolchain — the compiler, the full dependency cache, the source tree — as part of what runs in production, which is both unnecessarily large and a wider attack surface than it needs to be. A multi-stage build compiles in one stage using a full JDK-and-build-tool image, then copies only the resulting JAR into a second, separate stage based on a minimal JRE-only image; everything from the build stage that isn't explicitly copied over, including Maven itself and the source code, is discarded and never appears in the image you actually deploy.
Q3
What's the difference between a Kubernetes readiness probe and a liveness probe, and what happens when each one fails?
What's the difference between a Kubernetes readiness probe and a liveness probe, and what happens when each one fails?
A readiness probe asks "can this Pod currently handle traffic?" — when it fails, Kubernetes doesn't restart the Pod, it simply removes it from the Service's routing until the probe starts succeeding again, which is the right response to a Pod that's temporarily overloaded or still warming up a connection pool. A liveness probe asks a different question, "has this process gotten into a state it can't recover from on its own?" — when it fails, Kubernetes kills and restarts the container entirely, which is the right response to something like a deadlocked thread pool that will never self-heal. Pointing both at the same endpoint is a common mistake: it turns a temporary readiness dip into an unnecessary container restart.
Q4
Why do resource requests and limits matter in a Kubernetes Deployment?
Why do resource requests and limits matter in a Kubernetes Deployment?
The requests value is what the scheduler reserves on a node before it will even place the Pod there — without it, Kubernetes has no idea how much CPU or memory to plan around, and can overpack a node until every Pod on it is starved. The limits value is a hard ceiling: a container that exceeds its memory limit gets killed (OOMKilled) and restarted, and one that exceeds its CPU limit gets throttled rather than allowed to consume the whole node. Without limits, one runaway Pod — a memory leak, an infinite loop — can starve every other Pod scheduled on the same node; without requests, the scheduler can't make good placement decisions in the first place. Together they're what makes a shared cluster predictable instead of a source of noisy-neighbor incidents.