Containerizing the Application
A multi-stage, layered, non-root image and a Compose smoke test against PostgreSQL 18.
You are viewing a free preview lesson.
Until now, the application has run on the developer machine with ./gradlew bootRun, with PostgreSQL in Docker Compose. That remains our development model, because forcing every code change through a container rebuild would make Java development slow. Production delivery needs something else: one reproducible unit that moves through CI, non-production and production without being rebuilt for every machine. That is application containerization, backlog item BACKEND-127 Containerize the Application. The goal of this lesson:
Package the existing Spring Boot application into a reproducible container image that runs the same application artifact across environments while receiving environment-specific configuration at runtime.
What Are We Actually Shipping?
The build stays ./gradlew build (or bootJar), producing build/libs/order-management-0.0.1-SNAPSHOT.jar; the container packages that result, it does not replace it. Without a container, production depends on assumptions about the installed Java version, filesystem layout, startup command and operating-system packages, and machines drift. An image bundles all of them with the artifact into one deployable unit. It does not fix bugs, migrations, bad configuration, provider outages, capacity planning, backups or monitoring: containerization is packaging, not a substitute for the earlier modules' engineering.
One Application, One Image
Our architecture is one deployable modular monolith in which Product, Inventory, Order, Payment and Security are capability boundaries. "We use Docker, so each module becomes a container" is a common misconception: container boundaries follow the deployment architecture, and ours has one deployable application, so we build one image. It contains a Java runtime and the application; everything the application talks to stays outside:
Drawing diagram…
PostgreSQL stays a separate service even locally; an order-management-with-postgres image would couple two lifecycles. The Payment Provider is still reached through PaymentService with its URL as runtime configuration, and identity stays with the external identity provider (8.02).
Configuration and Secrets Stay Outside the Image
Module 3's rule matters even more now: one artifact, with environment-specific behaviour supplied at runtime. The same image runs in test, non-production and production with a different database, Payment Provider endpoint, credentials and log format. This is bad:
<!-- fragment -->ENV DB_URL=jdbc:postgresql://prod-db.internal:5432/order_management
ENV DB_PASSWORD=real-secret
The image now holds environment-specific and secret data: it cannot be promoted to another environment, and anyone who can pull it can unpack its layers and read the password, even if a later layer deletes a copied secrets.yml or production.env. Secrets belong to the platform's secret mechanism; if the Payment Provider later requires client certificates or keys, they are provisioned the same way.
The container receives its configuration when it starts:
docker run \
-e DB_URL=jdbc:postgresql://postgres:5432/order_management \
-e DB_USERNAME=order_management \
-e DB_PASSWORD=local-only \
-e PAYMENT_PROVIDER_BASEURL=https://sandbox.payments.example \
order-management:local
DB_URL, DB_USERNAME and DB_PASSWORD fill the placeholders in our application.yml (6.05), and relaxed binding maps PAYMENT_PROVIDER_BASEURL to payment.provider.base-url; 12.03 documents the full contract. Whether a value comes from a variable, a secret or a mounted file is invisible to the application. The model is build once, configure later: one commit builds image A, the pipeline tests image A, and the same image A goes to non-production and then production.
A First, Naive Dockerfile
The image is defined in a Dockerfile at the repository root, next to compose.yaml; module 3 said such files arrive with the work that needs them. A first attempt:
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./gradlew bootJar
CMD ["java", "-jar", "build/libs/order-management-0.0.1-SNAPSHOT.jar"]
This might work, but the final image contains a JDK, the whole Gradle project, source, tests, Gradle caches and possibly Git history and local files, while production needs only a Java runtime and the application. The startup command also hardcodes the version in the JAR name, so a version bump breaks it.
Multi-Stage Builds
A multi-stage build separates the build environment from the runtime environment. The builder stage has a JDK, the Gradle wrapper and the source, and produces the JAR. The runtime stage starts from a fresh, smaller base image and copies in only what the running application needs; everything else is discarded. The result has less tooling and a smaller attack surface: the builder compiles, the runtime image runs, and it needs a JRE, not javac.
The builder uses ./gradlew, not a Gradle installed in the image, so container builds use the same project-defined Gradle version as developer machines and CI.
Choosing the Base Image
Our build.gradle declares a Java 21 toolchain, so the runtime must be Java 21 too; building with 21 and running on 17 fails at startup with UnsupportedClassVersionError. We use Eclipse Temurin: eclipse-temurin:21-jdk for the builder and eclipse-temurin:21-jre for the runtime. The 21 tag follows the latest Java 21 update on the current Ubuntu LTS base, which is what we found in our image:
$ docker run --rm --entrypoint sh order-management:local -c 'java -version; . /etc/os-release; echo $PRETTY_NAME'
openjdk version "21.0.12.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-21.0.12.1+1 (build 21.0.12.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.12.1+1 (build 21.0.12.1+1-LTS, mixed mode, sharing)
Ubuntu 26.04.1 LTS
The tag pins the major version, so a rebuild picks up security fixes; never use an unbounded latest, and pin the digest (eclipse-temurin:21-jre@sha256:…) if you need byte-for-byte reproducible bases. Organizations often prescribe an approved image. Alpine-based images are smaller, but musl instead of glibc and missing diagnostic tools can bite; correctness, security and maintainability come before size.
Exactly One JAR
Spring Boot's Gradle plugin builds two archives when ./gradlew build runs: the executable bootJar and a plain jar with only our classes.
$ ./gradlew build -x test && ls build/libs
order-management-0.0.1-SNAPSHOT-plain.jar
order-management-0.0.1-SNAPSHOT.jar
Our Dockerfile below refers to build/libs/*.jar, which assumes exactly one. Its builder runs only bootJar, but change that step to the usual ./gradlew build and the pattern matches two; an image must never depend on which file a pattern picks. We do not need the plain archive, so we switch it off in build.gradle, and every build leaves only the executable JAR:
tasks.named('jar') {
enabled = false
}
Layered Extraction
Our JAR is 67 MB, of which 280 kB are our classes and 64 MB dependencies. Copied as one file, every one-line change produces a new 67 MB layer that every build stores and every deployment downloads, although dependencies rarely change. Spring Boot JARs carry a layer index, and Spring Boot 4 splits them with the tools jar mode (the older layertools mode is gone):
$ java -Djarmode=tools -jar build/libs/order-management-0.0.1-SNAPSHOT.jar list-layers
dependencies
spring-boot-loader
snapshot-dependencies
application
The layers run from least to most frequently changing: released libraries, Spring Boot's launcher, -SNAPSHOT libraries, and our classes and resources, Flyway migrations included. extract --layers --launcher writes one directory per layer, keeping the launcher so the application starts with org.springframework.boot.loader.launch.JarLauncher. Each directory becomes one COPY, so Docker reuses the unchanged ones.
The Complete Dockerfile
<!-- file: Dockerfile -->FROM eclipse-temurin:21-jdk AS builder
WORKDIR /workspace
COPY gradlew settings.gradle build.gradle ./
COPY gradle gradle
COPY src src
RUN --mount=type=cache,target=/root/.gradle \
./gradlew bootJar --no-daemon
RUN java -Djarmode=tools -jar build/libs/*.jar \
extract --layers --launcher --destination extracted
FROM eclipse-temurin:21-jre
RUN groupadd --system app \
&& useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --from=builder /workspace/extracted/dependencies/ ./
COPY --from=builder /workspace/extracted/spring-boot-loader/ ./
COPY --from=builder /workspace/extracted/snapshot-dependencies/ ./
COPY --from=builder /workspace/extracted/application/ ./
USER app
EXPOSE 8080 8081
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
The --mount=type=cache line (a BuildKit feature, the default builder in current Docker) keeps Gradle's distribution and dependency downloads between builds without putting them into any layer. The runtime stage creates an unprivileged user, copies the four layers in order, switches to that user and starts Java directly.
Building and Inspecting the Image
$ docker build -t order-management:local .
$ docker images order-management:local
REPOSITORY TAG IMAGE ID CREATED SIZE
order-management local 38415c0d4d55 5 seconds ago 408MB
$ docker history --format '{{.Size}}\t{{.CreatedBy}}' order-management:local | head -7
0B ENTRYPOINT ["java" "org.springframework.boot…
0B EXPOSE map[8080/tcp:{} 8081/tcp:{}]
0B USER app
280kB COPY /workspace/extracted/application/ ./ # …
0B COPY /workspace/extracted/snapshot-dependenc…
402kB COPY /workspace/extracted/spring-boot-loader…
66.8MB COPY /workspace/extracted/dependencies/ ./ #…
Most of the 408 MB is the Temurin JRE base, shared by every image built on it. After changing one class and building again, BuildKit reported:
#12 [builder 6/7] RUN --mount=type=cache,target=/root/.gradle ./gradlew bootJar --no-daemon
#12 DONE 5.1s
#16 [stage-1 5/7] COPY --from=builder /workspace/extracted/spring-boot-loader/ ./
#16 CACHED
#17 [stage-1 4/7] COPY --from=builder /workspace/extracted/dependencies/ ./
#17 CACHED
#19 [stage-1 7/7] COPY --from=builder /workspace/extracted/application/ ./
#19 DONE 0.0s
Only the 280 kB application layer is new, so a registry or server that has the previous version downloads only that. The Gradle step took 5 seconds instead of 39, thanks to the cache mount. Instruction order matters too: a step is reused only when it and all earlier steps are unchanged, so an early COPY . . invalidates everything after it.
The Build Context and .dockerignore
docker build . sends the build context, usually the repository root, to the builder. Without care it includes .git/, build/, .gradle/, IDE metadata and local environment files. A .dockerignore excludes them:
.git
.gradle
build
.idea
.vscode
*.iml
.DS_Store
.env
.env.*
Speed is the smaller benefit. Local .env files with secrets never reach the builder, and excluding the host's build/ forces the builder to produce the artifact itself instead of silently using an old local JAR. Be careful in the other direction: src/main/resources, with application.yml and the Flyway migrations, is part of the artifact and must stay.
The same principle rules out COPY build/libs/*.jar in a Dockerfile that assumes a developer already ran Gradle: then docker build depends on hidden local state. In 3.01 a clean checkout had to build with ./gradlew build; now it must also build the image with docker build ., with no IDE state, no manually prepared JAR and no local-only file.
Tests and the Docker Build
The builder runs bootJar, not build, so no tests run inside docker build, and a successful image build does not mean the tests passed. Artifact creation and quality verification are different concerns. The next ticket, BACKEND-128 Create CI Build and Test Pipeline, makes CI the authoritative place for compiling and running unit and Testcontainers tests before it builds the image; repeating the suite inside Docker would duplicate work and need a container runtime inside the build.
Two models are common. Ours builds the JAR inside Docker, so the image build is self-contained with a controlled Java environment. The alternative lets CI build and test the JAR and the Dockerfile only copies that verified JAR into the runtime stage. With our choice, CI compiles once for the tests and once more in the image build; that is acceptable for now, as long as both come from the same commit, wrapper and dependency versions, and 12.02 revisits the trade-off.
Exec-Form Entrypoint and Signals
The ENTRYPOINT uses the exec form, a JSON array. The shell form, ENTRYPOINT java org.springframework.boot.loader.launch.JarLauncher, wraps the command in /bin/sh -c, which changes who receives the termination signal. We built both variants and stopped them:
| Shell form | Exec form | |
|---|---|---|
| PID 1 | sh (Java is PID 7) | java |
docker stop took | 10.2 s | 0.7 s |
| Exit code | 137 | 143 |
With the shell form, sh is PID 1 and does not forward SIGTERM; docker stop waits its default 10 seconds, then sends SIGKILL (137), cutting off in-flight requests. With the exec form, Java is PID 1, receives SIGTERM (143 = 128 + 15), and Spring Boot shuts down gracefully, its default:
INFO 1 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
INFO 1 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
INFO 1 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
Tomcat stops accepting requests, lets active ones finish, and the pool closes; 12.05 relates the shutdown timeout to the platform's under load. A container runs one main process and exits with it, so Spring Boot, PostgreSQL and cron never share one container.
Running as a Non-Root User
A container process runs as root unless the image says otherwise, yet the application only reads its files, listens on ports above 1024 and uses the network. If a vulnerable library ever lets an attacker run code, least privilege limits the damage, so the runtime stage switches to a system user:
$ docker run --rm --entrypoint sh order-management:local -c 'id; ls -l /app'
uid=999(app) gid=999(app) groups=999(app)
drwxr-xr-x 1 root root 4096 Oct 22 21:44 BOOT-INF
drwxr-xr-x 3 root root 4096 Oct 22 21:44 META-INF
drwxr-xr-x 3 root root 4096 Oct 22 21:44 org
We deliberately did not --chown the files to app: they stay owned by root, so the process cannot modify its own code. Other base images have other user commands; the principle is the same.
Ports and Container Networking
EXPOSE 8080 8081 documents the API port and the Actuator management port from 11.03; it publishes nothing. Publishing is runtime configuration (-p 8080:8080 locally, the platform's networking in production), so we invent no container-only port. Networking also changes what localhost means. On the developer machine, localhost:5432 reaches PostgreSQL through its published port; inside the application container, localhost is this container. On a shared Docker network, the application reaches the database by service name: jdbc:postgresql://postgres:5432/order_management.
Running the Container Against PostgreSQL 18
For a full-container smoke test we add the application to compose.yaml under a profile, so the everyday docker compose up -d still starts only the services of earlier modules:
services:
app:
profiles: ["app"]
build: .
image: order-management:local
depends_on:
postgres:
condition: service_healthy
environment:
DB_URL: jdbc:postgresql://postgres:5432/order_management
DB_USERNAME: order_management
DB_PASSWORD: local-only
PAYMENT_PROVIDER_BASEURL: https://sandbox.payments.example
PAYMENT_PROVIDER_CONNECTTIMEOUT: 1s
PAYMENT_PROVIDER_READTIMEOUT: 5s
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8081/actuator/health/readiness"]
interval: 2s
retries: 30
ports:
- "8080:8080"
- "8081:8081"
local-only is a throwaway local credential. The container runs without the local profile, so it receives what that profile supplied to bootRun: the database, and the provider's address and timeouts from 9.03 (an upper-case environment variable reaches payment.provider.connect-timeout; 12.03 explains how). docker compose --profile app up -d --build --wait builds the image (without --build, Compose reuses any existing order-management:local, however old), waits for pg_isready, starts the application and returns once its readiness probe is healthy. On a fresh database the log shows the whole path, from Java 21 running as app to Flyway on PostgreSQL 18 (timestamps and logger names trimmed):
Starting OrderManagementApplication v0.0.1-SNAPSHOT using Java 21.0.12.1 with PID 1 (/app/BOOT-INF/classes started by app in /app)
Database: jdbc:postgresql://postgres:5432/order_management (PostgreSQL 18.6)
Successfully applied 7 migrations to schema "public", now at version v7 (execution time 00:00.011s)
Tomcat started on port 8080 (http) with context path '/'
Tomcat started on port 8081 (http) with context path '/'
Started OrderManagementApplication in 2.88 seconds (process running for 3.076)
$ curl -s -w ' %{http_code}\n' localhost:8081/actuator/health/readiness
{"status":"UP"} 200
$ docker compose ps --format '{{.Service}} {{.Status}}'
app Up 4 seconds (healthy)
postgres Up 7 seconds (healthy)
The smoke test proves what unit and integration tests cannot: the image starts, configuration arrives from outside, the database connection works, Flyway runs and readiness answers. Compose stays a local tool, not the production architecture, and a container or pod name is an instance identity no business logic may depend on.
Startup Failure Must Stay Visible
Containerization does not change migration semantics: Flyway runs at application startup as configured and owns the schema alone. Do not add a container init script that runs its own SQL next to it. If configuration is invalid or a migration fails, the application must not pretend to be healthy. We started the image with a wrong database password:
Message : FATAL: password authentication failed for user "order_management"
ERROR 1 --- [ main] o.s.boot.SpringApplication : Application run failed
$ docker ps -a --format '{{.Names}}\t{{.Status}}'
app-bad Exited (1) Less than a second ago
Spring Boot fails, Java exits and the container exits with status 1, so the deployment system sees the failure and routes no traffic. Keeping a broken container alive with tail -f /dev/null or a shell loop "for debugging" defeats exactly that.
Health Checks and the Container
11.03 left one decision to this ticket: a Docker HEALTHCHECK in the image, or HTTP probes from the platform. Every serious platform can call /actuator/health/liveness and /actuator/health/readiness on 8081 from outside, and probe intervals and thresholds are deployment configuration. So the Dockerfile gets no HEALTHCHECK. Our Compose healthcheck is only for the local smoke test and works because the Temurin base happens to include curl; do not install tools just for a probe, since minimal images such as the Paketo one below have no shell or curl at all.
Verification waits for readiness, not for a fixed time. docker run ...; sleep 20; curl ... fails when startup takes 25 seconds in CI and wastes time when it takes 5 locally; poll with a bounded timeout, as --wait does.
Logs, Metrics and the Filesystem
Containerized applications log to stdout and stderr, and the platform collects the streams. We already do that: production switches to ECS JSON with LOGGING_STRUCTURED_FORMAT_CONSOLE=ecs (11.01), and log lines still carry requestId (11.02), orderId and event.action. Do not add /logs/application.log, a host log volume or rotation scripts: containers are restarted, replaced and rescheduled, and their local files disappear with them. Metrics work as before, with Prometheus scraping /actuator/prometheus on 8081 (11.05); the image contains no metrics database.
Orders, Inventory and payment state live in PostgreSQL, never in /app/data, and our application writes no files at all. If a future feature needs temporary files, they are disposable unless durable external storage is introduced deliberately; we add no volumes speculatively.
JVM Memory in a Container
Java 21 is container-aware: it reads the container's cgroup limits instead of the host's memory and CPU count. By default the maximum heap is 25% of the memory limit. With a 1 GiB limit:
$ docker run --rm --memory=1g --entrypoint java order-management:local -XX:+PrintFlagsFinal -version | grep -E ' MaxHeapSize|MaxRAMPercentage '
size_t MaxHeapSize = 268435456 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
256 MiB of heap in a 1 GiB container is often conservative for a server that runs nothing else, but heap is only part of the JVM's memory, next to metaspace, thread stacks, code cache and direct buffers, and the right value comes from measured workload. So we do not hardcode -Xmx256m or GC flags copied from another project into the ENTRYPOINT. When tuning is needed, it is relative to the limit and supplied at runtime:
$ docker run --rm --memory=1g -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 --entrypoint java order-management:local -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'Picked up| MaxHeapSize'
Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75
size_t MaxHeapSize = 805306368 {product} {ergonomic}
The JVM reads JAVA_TOOL_OPTIONS itself, so the exec-form ENTRYPOINT needs no shell to expand variables, and the same image works with any limit.
The Alternative: bootBuildImage
Spring Boot's Gradle plugin can also build an image without a Dockerfile, using Cloud Native Buildpacks (Paketo):
$ ./gradlew bootBuildImage --imageName=order-management:buildpacks
> Pulling builder image 'docker.io/paketobuildpacks/builder-noble-java-tiny:latest' ...
[creator] BellSoft Liberica JRE 21.0.12: Contributing to layer
[creator] Paketo Buildpack for Spring Boot 5.40.3
Successfully built image 'docker.io/library/order-management:buildpacks'
The buildpacks read Java 21 from the JAR's manifest, add a JRE, split the layers, run as a non-root user (1002:1001) and produce a 322 MB image on a minimal base without a shell. At startup a memory calculator derives the JVM flags from the container limit (Calculated JVM Memory Configuration: ... -Xmx406252K ... (Total Memory: 1G, ...)). It suits teams that want secure defaults without maintaining a Dockerfile (they should pin the builder, which defaults to latest). We keep the Dockerfile, because every step is visible; both reach the same result.
Image Identity and Maintenance
A deployment must be able to say exactly which image is running, and order-management:latest cannot, because latest moves. Pipelines tag images with the Git commit SHA (12.02), and registries identify each image by an immutable digest: the tag is the human-friendly reference, the digest the content identity.
Images are immutable: build, then deploy. A container that runs apt upgrade, downloads dependencies or edits configuration at startup depends on package repositories, the network and the time it started. Startup should be boring: configuration arrives, Java starts, Spring runs Flyway and becomes ready.
Immutable does not mean untouched. The base image is part of our artifact, so a JRE or operating-system security patch can justify rebuilding and redeploying even without a source change.
Local Development Stays the Same
The README keeps both workflows and gains a "Container image" section with the docker build command, the --profile app smoke test and the required runtime variables, because documentation describes what actually works.
The Dockerfile and .dockerignore are production code: a broken one can use the wrong Java, run as root, embed secrets or break startup. Both are reviewed like build.gradle and exercised by building and running the image.
BACKEND-127: Scope and Acceptance Criteria
# BACKEND-127 — Containerize the Application
## Out of Scope
- A production orchestrator, Kubernetes manifests, a registry, CI/CD (BACKEND-128).
- Microservices; PostgreSQL inside the image; JVM tuning; Prometheus or Grafana; autoscaling.
## Acceptance Criteria
- A clean checkout builds the image with `docker build .`.
- The runtime image runs Java 21 and contains no source code, Gradle or JDK.
- Environment-specific configuration is supplied at runtime; no credentials are in any layer.
- Java runs as the container's main process, as a non-root user.
- With valid configuration and PostgreSQL available, readiness on port 8081 becomes `UP`.
- With invalid startup configuration, the container exits with a non-zero status.
- Logs go to standard output; the README documents the container build and smoke test.
Practice Exercise 1: Audit the Image
Solutions: lesson 12.06.
Build with docker compose --profile app build. Predict the process's user, whether the migrations and javac are in the image, and whether the process can write to /app. Check, and name the Dockerfile line behind each answer:
docker run --rm --entrypoint sh order-management:local -c \
'id; ls /app/BOOT-INF/classes/db/migration; command -v javac || echo no javac; touch /app/x'
Practice Exercise 2: Review a Dockerfile
Find five problems in this proposal, with the consequence of each:
<!-- fragment -->FROM eclipse-temurin:latest
WORKDIR /app
COPY . .
ENV DB_PASSWORD=local-only
RUN ./gradlew build -x test
ENTRYPOINT java -Xmx256m -jar build/libs/*.jar
Knowledge Check
Question 1
Why does a green docker build say nothing about the tests?
Question 2
After a one-line change, why does a server with the previous image download only kilobytes?
Question 3
Why is a container that exits on a wrong password better than one kept alive for debugging?
Knowledge Check Answers
Answer 1
The builder runs bootJar, not build; the tests run in CI (12.02).
Answer 2
Only the application layer changed; the others are identical and already there.
Answer 3
The exit tells the platform the instance failed, so it gets no traffic and the rollout stops; kept alive, it hides the failure.
Engineering Principle
A container is a reproducible packaging and runtime boundary, not a new application architecture. Build the application into the image, keep environment-specific configuration and secrets outside it, and promote the same image across environments.
Summary
- Containerization is packaging: one reproducible image of the modular monolith, with PostgreSQL, the Payment Provider and identity outside, and configuration and secrets supplied at runtime (
DB_URL,DB_USERNAME,DB_PASSWORD, …). - A multi-stage
Dockerfilebuilds with the Gradle wrapper oneclipse-temurin:21-jdkand runs oneclipse-temurin:21-jre; a disabled plainjarleaves exactly one JAR. java -Djarmode=tools -jar … extract --layers --launchersplits the JAR, so a code change rebuilds and ships only a small layer..dockerignoreand a clean-checkout build keep secrets and stale host artifacts out; tests stay in CI, which means compiling twice for now.- The exec-form
ENTRYPOINTmakes Java PID 1, soSIGTERMtriggers Spring Boot's graceful shutdown; the shell form was killed after 10 seconds. - The container runs as a non-root user, exposes 8080 and 8081, reaches PostgreSQL 18 by service name, exits visibly on startup failure and is probed on readiness by the platform.
- Logs go to stdout, state lives in PostgreSQL, and JVM memory is tuned, if at all, at runtime with
-XX:MaxRAMPercentage. bootBuildImage(Paketo) is aDockerfile-free alternative; images are immutable, identified by commit SHA and digest, and rebuilt when the base image is patched.
In the next lesson, Creating a CI Build and Test Pipeline, we implement BACKEND-128: a clean checkout, the Gradle build with unit and Testcontainers integration tests, and the container image build run on every change, with traceable artifact identity, caching and quality gates before merge, without yet building a complete production CD system.
Think first
Coming soonCheckpoint questions that ask you to commit to an answer before the lesson shows it.