Week 13: Containerization & Deployment

Everything built through Week 12 — a tested, secured, observable Task service plus its Notification companion — now gets packaged and shipped the way production teams actually do. This week is about turning that codebase into container images, wiring up an automated pipeline that runs the Week 8 test suite before anything gets deployed, and making your first real deployment to Kubernetes.

Module 10 of 12 Week 13 of 15 ~5–6 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build a Spring Boot container image with Cloud Native Buildpacks, and know when to hand-write a Dockerfile instead
  • Automate build, test and deploy with a CI/CD pipeline in GitHub Actions
  • Deploy a service to Kubernetes with a basic Deployment and Service manifest

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.

terminal
# 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:

image layers, least to most volatile
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.

This is the capstone's deployment path

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.

Dockerfile
# ---- 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:

.dockerignore
target/
.git/
.idea/
*.iml
.env
.env.local
*.log
Dockerfile
.dockerignore
Pick one, know both

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.

.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:
          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.

Why the Week 8 suite specifically

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

task-service-deployment.yaml
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

task-service-service.yaml
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:

task-service-config.yaml
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.

Full circle from Week 12

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

Hands-on

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:

  1. Build a container image for the Task service with ./mvnw spring-boot:build-image, then separately write a multi-stage Dockerfile and build it with docker build. Compare the two images' sizes with docker images and note the wall-clock build time for each on a clean cache.
  2. Write a .dockerignore for the hand-written Dockerfile so target/, IDE metadata, and any local env files are excluded from the build context.
  3. Write a GitHub Actions workflow at .github/workflows/ci.yml with a test job that runs ./mvnw verify (the full Week 8 suite, including the Testcontainers tests) on every push and pull request, and a build-and-push job that only runs on main after test succeeds, using needs: to enforce the ordering.
  4. Write task-service-deployment.yaml and task-service-service.yaml. The Deployment must set replicas: 3, a resources.requests and resources.limits block, and readinessProbe/livenessProbe pointed at the /actuator/health/readiness and /actuator/health/liveness endpoints from Week 12.
  5. Write a task-service-config.yaml containing a ConfigMap with at least SPRING_PROFILES_ACTIVE, and reference it from the Deployment via envFrom. Validate every manifest with kubectl apply --dry-run=client -f . before considering the exercise done.
Hint

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?

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?

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?

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?

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.