Server image build via Paketo Buildpacks + Application CDS
The application-server image is built by mvn spring-boot:build-image (Paketo Cloud Native Buildpacks) with Application Class Data Sharing (CDS) enabled. The hand-rolled Dockerfile was removed. Spring Boot AOT processing is off (see below).
Why
Production warm startup was ~13.3s/pod (post-#1281 baseline); across a 5-replica Coolify rolling-restart this is a perceivable p95 latency window. Spring's published CDS numbers on Spring MVC + Tomcat are ~1.5× faster startup (Spring blog 2024-08-29). Liquibase keeps its absolute share regardless; the realistic projection is ≥30% off the Spring portion of boot.
What the build does
builder-noble-java-tiny applies the Spring Boot buildpack. With BP_JVM_CDS_ENABLED=true the launcher runs once at build time (the "CDS training run") to load the bean-graph classes, archives them to /workspace/application.jsa, and bakes -XX:SharedArchiveFile=/workspace/application.jsa into the launcher. At runtime the JVM mmaps the archive instead of class-loading from JARs.
The training run boots under the cds-training profile (application-cds-training.yml), which disables Liquibase + JDBC-metadata probing and pins the Hibernate dialect so context refresh succeeds without a reachable Postgres. Coolify's runtime SPRING_PROFILES_ACTIVE=prod overrides the buildpack-baked default (Paketo writes env.launch/<KEY>.default, which yields to the runtime env).
Why not Spring AOT processing (spring.aot.enabled=true)
AOT processing evaluates @Conditional at build time, baking the build-time environment into the image (reference). The codebase has ~70 @ConditionalOn* sites (Sentry, Slack, PostHog, Resilience4j, etc.) that depend on env vars intentionally absent in CI — enabling AOT now would silently drop those beans from the production image. Revisit when the JDK move to Java 25 LTS lands (epic #1096); JEP 483 AOT cache then supersedes CDS via BP_JVM_AOTCACHE_ENABLED=true.
Why not GraalVM Native Image
8–12× CI build inflation, loss of JIT peak throughput on Hibernate workloads, no JFR/JVMTI/debugger, closed-world model breaks @ConditionalOn* runtime overrides, and three reflective deps (com.slack.api:bolt, kobylynskyi runtime, liquibase-core:5.x) lack published reachability metadata.
Builder pinning
pom.xml pins builder-noble-java-tiny + ubuntu-noble-run-tiny + the health-checker buildpack by
sha256 digest. Refresh:
docker buildx imagetools inspect paketobuildpacks/builder-noble-java-tiny:latest --format '{{.Manifest.Digest}}'
docker buildx imagetools inspect paketobuildpacks/ubuntu-noble-run-tiny:latest --format '{{.Manifest.Digest}}'
docker buildx imagetools inspect paketobuildpacks/health-checker:latest --format '{{.Manifest.Digest}}'
Bump as part of release cycles; the digest is the source of truth.
Container healthcheck on the distroless run image
On Docker Compose the container HEALTHCHECK is the only container-level health signal —
service_healthy gating and the docker compose ps column both depend on it. run-tiny has no
shell/wget and builder-noble-java-tiny bundles no probe, so pom.xml adds an explicit <buildpacks>
order. Specifying <buildpacks> replaces the builder's default order, so the java composite must
be re-listed (urn:cnb:builder:paketo-buildpacks/java) before appending
docker://paketobuildpacks/health-checker; BP_HEALTH_CHECKER_ENABLED=true opts it in. It contributes
the static, shell-free thc binary at /workspace/health-check, which the compose services invoke as an
exec-form HEALTHCHECK (THC_PORT/THC_PATH → actuator liveness/readiness). No health-check process
type is added, so the JVM-spawn-per-probe issue (health-checker#87) does not apply.
git CLI in the runtime image
GitDiffOperations previously shelled out to git; it was ported to JGit in the prerequisite commit, eliminating the runtime git dependency. The Paketo run image is used unmodified.
Rollback
Revert .github/workflows/ci-docker-build.yml (the use-buildpacks: true line) and re-add a Dockerfile. Coolify re-deploys the prior image SHA. Detection: Sentry release-tagged error spike, or Prometheus alert on application_ready_time_seconds > 15 for three consecutive deploys.
Operational checklist
- Coolify graceful shutdown —
application.ymlsetsSHUTDOWN_TIMEOUT:20s. Coolify's default container stop-grace is 10s; bump it to ≥25s in the deploy substrate so SIGTERM has time to drain in-flight requests. The Paketo launcherexecs the JVM; signal forwarding is native, notini. - JVM memory — do NOT set
MaxRAMPercentage,-Xmx, or-Xssin Coolify env. Paketo's memory calculator handles them. Override onlyBPL_JVM_HEAD_ROOMif needed. - SBOM — Paketo emits Syft + SPDX + CycloneDX at
/layers/sbom/. CI extracts viapack sbom downloadand uploads as a 90-day artifact. - CVE scan — Trivy runs on every PR and uploads SARIF to GitHub Security. Until the baseline is clean, results are non-blocking; flip
--exit-code 1in the workflow once HIGH+ is at zero. - CI build time — expect +60–120s per build vs the prior Dockerfile baseline (CDS training run dominates).
Sources
- Spring Boot 4 — Class Data Sharing how-to
- Spring Boot 4 — Packaging OCI Images
- Spring Boot 4 — Ahead-of-Time Processing
- OpenJDK JEP 483 — Ahead-of-Time Class Loading & Linking
- paketo-buildpacks/spring-boot
- paketo-buildpacks/spring-boot#571 — BP_JVM_CDS_ENABLED deprecation track
- Spring blog 2024-08-29 — CDS + Project Leyden