Keeping Your Code, Dependencies,
and Runtime Up to Date

Practical strategies for modern software teams

Your Name
Software Development Conference 2026

Java 25 Spring Boot Docker GraalVM Renovate

👀 Follow Along — Every Example Is Live

All demos, workflows, and reports in this talk live in one public repo

🔒 github.com/johanjanssen/Keep-Up-To-Date
Keep-Up-To-Date repository preview

↗ github.com/johanjanssen/Keep-Up-To-Date

Scripts, workflow definitions, and the live GitHub Pages reports shown later all come straight from this repo — clone it and run along.

Agenda

  1. 🚨 Why This Actually Matters
  2. ☕ Runtime: Ride the Release Train
  3. 📦 Dependencies: Manual → Automated
  4. 🔍 Security Scanning — Two Layers
  1. 🐳 Container Images: The Layer You Forgot
  2. 🤖 Renovate — Automate Everything
  3. 🧪 Making Updates Safe with Tests
  4. 🏁 Key Takeaways & Tools

🚨 The "It Works, Don't Touch It" Trap

"Projects don't drift maliciously — just one sprint at a time."

  • Every application has direct and transitive dependencies — often hundreds
  • You consciously chose a small fraction of them
  • The rest came with the territory — and each carries potential CVEs
☕ Java example
A typical Spring Boot app: 50–80 direct, 200–400 transitive dependencies.
./mvnw dependency:tree | wc -l — prepare to be surprised.

When Drift Becomes a Crisis

Log4Shell — Dec 2021

  • CVSS 10.0 — maximum score
  • 93% of cloud environments exposed
  • Library vendor patched in 2 hours
  • Most teams took weeks to deploy

Root cause: a logging library, unused by many teams, hadn't been reviewed in years

Spring4Shell — 2022

  • CVE-2022-22965 in the framework itself
  • Teams on old versions couldn't patch directly — had to upgrade the framework first

The version gap made patching a multi-week project instead of a one-day fix

The Compounding Cost of Delay

  • Security: each skipped version = another version to jump over when a CVE forces your hand
  • Performance: runtime improvements accumulate — you leave free speed on the table
  • Migration effort: small incremental hops are easy; multi-year jumps require dedicated projects
☕ Java example
Java 8 → 21 in one jump: complex module-system breakage, API removals, GC changes.
Java 8 → 11 → 17 → 21 incrementally: each hop is manageable and well-documented.

"If it hurts, do it more often." — Martin Fowler

Mean Time to Patch (MTTP)

Your most important security metric

Update approachTypical MTTPRating
Manual, quarterly review30–90 days❌ Dangerous
Weekly manual check7–14 days⚠️ Acceptable
Automated bots + auto-merge30 min – 2 hours✅ Best practice

Industry benchmark: critical CVE → patched in production within 72 hours.
You cannot hit that with manual quarterly updates.

☕ Ride the Release Train

Modern runtimes and languages ship on a predictable cadence — not when they feel ready, but on a fixed schedule.

  • Regular releases → smaller, manageable changes each time
  • LTS (Long-Term Support) releases get extended security patches — safe for production
  • Non-LTS releases are production-quality for short-lived or internal services
☕ Java example New release every 6 months (March / September). LTS every 2 years.
Java 8
LTS 2014
Java 11
LTS 2018
Java 17
LTS 2021
Java 21
LTS 2023 🧵
Java 25
LTS Sep 2025 ⚡

Which Runtime Distribution?

Most ecosystems have multiple vendors distributing the same runtime — all standards-compliant, but with different support models and extras.

☕ Java example
DistributionVendorGood for
Eclipse TemurinAdoptiumStandard apps, Docker (used in this project)
Azul ZuluAzulCRaC checkpointing (patched JVM)
GraalVM CEOracle / communityNative image compilation
Amazon CorrettoAWSAWS-native workloads

💡 It matters less than you think — all are standards-compliant. Pick one and commit to updating it.

Pin Versions, Not Floating Tags

Whether it's a Docker base image, a language runtime, or a build tool — floating version tags silently change under you.

❌ Floating — dangerous
FROM eclipse-temurin:25-jre
# Rebuilt weekly. CI uses a stale
# cached layer from months ago.
✅ Pinned — reproducible
FROM eclipse-temurin:25-jre\
@sha256:7ea65de6...
Use automation to update the pin via PR.
☕ Java tooling Commit .sdkmanrc (java=25.0.1-tem) and mvnw / gradlew wrappers — everyone and CI run the exact same versions.

📦 The Dependency Graph Problem

Every package you install brings its own dependencies. Those bring theirs. The graph grows fast — and most of it is invisible to you.

  • You own the direct dependencies; you are responsible for the transitive ones
  • Each node is a potential CVE, a potential breaking change, a potential licence issue
  • Without tooling, the graph is effectively unmanageable

Use a Bill of Materials (BOM)

A BOM declares a curated, tested set of dependency versions. Upgrade the BOM, get a tested, compatible stack upgrade — not dozens of independent version decisions.

☕ Java example — Spring Boot parent POM
<parent>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.0</version>  <!-- one bump = 300+ libs updated -->
</parent>
Also upgrades: Jackson · Tomcat · Netty · Hibernate · Micrometer · Logback …
All versions tested together by the Spring team.

Discover Stale Dependencies

Build-tool plugins can compare your locked versions against the latest available in the registry — without modifying anything.

☕ Maven Versions Plugin
./mvnw versions:display-dependency-updates

# [INFO] org.postgresql:postgresql ...... 42.7.3 -> 42.8.0
# [INFO] jackson-databind .............. 2.17.1 -> 2.18.2
# [INFO] log4j-core .................... 2.0    -> 2.24.3  ← 🔴 CVE!
☕ Also useful
./mvnw versions:display-plugin-updates      # build plugins too
./mvnw versions:display-property-updates    # ${my.version} properties
Gradle equivalent: com.github.ben-manes.versions plugin → ./gradlew dependencyUpdates

Enforce Constraints at Build Time

Don't just warn — fail the build when forbidden or outdated dependencies appear. This prevents regressions from slipping in silently.

☕ Maven Enforcer Plugin
<rules>
    <requireJavaVersion><version>[21,)</version></requireJavaVersion>
    <bannedDependencies>
        <excludes>
            <exclude>log4j:log4j</exclude>         <!-- Log4j 1.x -->
            <exclude>commons-logging:commons-logging</exclude>
        </excludes>
    </bannedDependencies>
</rules>

SBOM — Know What You Ship

A Software Bill of Materials is a machine-readable inventory of every component in your artifact — like a nutrition label for software.

  • Required by US federal procurement (Executive Order 2021)
  • EU Cyber Resilience Act — mandatory for regulated products
  • Enables automated CVE matching across your entire fleet
☕ CycloneDX Maven Plugin
./mvnw org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
# Output: target/bom.json — attach to every release artifact

🔍 Two Layers of Scanning

They find different things — you need both

Layer 1: Source / Build Time

What you declared

  • Scans your dependency manifest
  • Fast, integrates with CI
  • Misses OS-level and transitive-only packages

Layer 2: Runtime / Image

What actually ships

  • Scans OS packages + language libs in the artifact
  • Finds what build-time scanning misses
  • Closer to what attackers see
☕ Java example Layer 1: OWASP Dependency Check scans pom.xml. Layer 2: Trivy / Grype scan the Docker image (OS pkgs + all JARs). A transitive JAR pulled in by a framework won't appear in Layer 1 if it's not declared.

Source-Level Scanning

Scan dependency manifests against a vulnerability database as part of the build. Fail the build on high-severity findings.

☕ OWASP Dependency Check
<plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <configuration>
        <failBuildOnCVSS>7</failBuildOnCVSS>  <!-- fail on HIGH+ -->
    </configuration>
</plugin>
bash "OWASP Dependency Check/scripts/run-check.sh"
# log4j-core-2.0.jar      → CVE-2021-44228 CRITICAL 10.0 🔴
# jackson-databind-2.9.10 → CVE-2019-14379 CRITICAL  9.8 🔴

▶ Run Workflow

Image / Artifact Scanning

Scan the built artifact (container image, fat JAR, etc.) for vulnerabilities in both the OS layer and bundled application dependencies.

☕ Grype (Anchore)
  • OS packages + language deps
  • Fast, offline-capable
  • SARIF → GitHub Security tab
bash Grype/scripts/scan-image.sh \
  eclipse-temurin:25-jre
☕ Trivy (Aqua)
  • OS packages + language deps
  • Secrets & misconfig scanning
  • Widest DB coverage
bash Trivy/scripts/scan-image.sh \
  eclipse-temurin:25-jre

▶ Grype ▶ Trivy

Two Scanners, Different Results

Different databases → different findings — use both for complete coverage

Live data — bash "Compare Security Scans/scripts/compare.sh"

▶ Compare Workflow

📊 Compare-Scans — Live Results

Unique CVEs (deduplicated by ID) — Grype vs Trivy, Tot/C/H/M/L/U

ImageGrypeTrivyUnique GrypeUnique Trivy
eclipse-temurin:25-jre96 / 0 / 2 / 87 / 7 / 060 / 0 / 8 / 47 / 4 / 14812
debian:12-slim79 / 6 / 13 / 24 / 5 / 3184 / 5 / 8 / 30 / 38 / 3-5
gcr.io/distroless/base-debian1215 / 1 / 2 / 3 / 1 / 815 / 0 / 0 / 6 / 9 / 0--
gcr.io/distroless/static-debian120 / 0 / 0 / 0 / 0 / 00 / 0 / 0 / 0 / 0 / 0--
hello-conference:jre-temurin110 / 2 / 6 / 95 / 7 / 074 / 2 / 12 / 55 / 4 / 14812
hello-conference:jlink-distroless-base29 / 3 / 6 / 11 / 1 / 829 / 2 / 4 / 14 / 9 / 0--
hello-conference:native-scratch0 / 0 / 0 / 0 / 0 / 00 / 0 / 0 / 0 / 0 / 0--
☕ Java example Same app, different base image: jre-temurin → 110 CVEs, native-scratch → 0 CVEs. On jre-temurin, Grype found 48 CVEs Trivy missed and Trivy found 12 CVEs Grype missed — neither tool is a superset of the other. (Grype's GHSA-id findings are resolved to their NVD CVE alias first, so a vulnerability both tools found under different id schemes isn't miscounted as "unique" to both.)

🔍 Compare-Scans — Full Report

Live, click-through report published to GitHub Pages — scroll, expand the full breakdown, no tab switch needed

↗ Open in new tab

Automate Security Alerts

Don't wait for a scheduled scan — get notified as soon as a CVE that affects your dependencies is published.

  • GitHub Dependabot Security Alerts — free, notifies within minutes of NVD publication
  • Auto-generates PRs: "Bump jackson-databind 2.13.0 → 2.13.4.2 (CVE-2022-42003)"
  • Combine with Renovate for scheduled updates + Dependabot for immediate security PRs

🐳 The Layer You Forgot

Every containerised application is built on a base image. That base image is a dependency — one that changes constantly, carries OS-level CVEs, and is easy to forget about.

  1. You wrote FROM mybase:v2 six months ago
  2. That tag now points to a different image — rebuilt with OS patches
  3. Your CI uses the cached layer from six months ago
  4. You are running vulnerable OS packages — and you don't know it

Reduce Attack Surface with Minimal Images

The fewer OS packages in your image, the smaller the attack surface. Most application runtimes don't need a shell, a package manager, or system utilities.

☕ Java image hierarchy
StrategyBaseCVEs (Grype)Size
Full JREeclipse-temurin:25-jre96477 MB
jlink minimaldistroless/base-debian121533 MB
GraalVM nativedebian:12-slim79116 MB
GraalVM native minimaldistroless/static-debian1206 MB
GraalVM native scratchscratch00 MB

Live scan + docker images data — base images, not the app image.

⚠ Your choice of base image is a security decision, not just a size decision.

Image Size vs CVE Count

hello-conference app — same code, 9 base images

🎬 Demo: Build All Images

bash "Build Docker Images/build-all-images.sh"    # build all 11 variants
bash "Build Docker Images/measure-images.sh"       # size + package counts
bash "Build Docker Images/measure-performance.sh"  # startup time + memory

▶ Run Workflow

🤖 Manual Updates Don't Scale

As a project grows, the number of dependencies that need attention grows faster than the team's capacity to review them.

  • Dependency bots watch registries continuously and open PRs automatically
  • The policy (what to group, what to auto-merge, what needs review) lives in config — not in people's heads
  • CI verifies each update before it lands — humans only intervene for breaking changes

Renovate vs Dependabot

FeatureDependabotRenovate
Maven / Gradle
Docker base images
CI workflow versions
Maven Wrapper version
Grouping updatesLimitedFull control
Self-hosted
Config in reporenovate.json
Stability days / merge confidence

Policy as Code

Define how updates are handled in a config file committed to the repo — not in a wiki, not in a person's memory.

{
  "extends": ["config:base"],
  "packageRules": [
    {
      "description": "Group related updates — one PR, one review",
      "matchPackagePatterns": ["^org.springframework"],
      "groupName": "Spring Framework",
      "schedule": ["every weekend"]
    },
    {
      "description": "Auto-merge low-risk patch updates for test libs",
      "matchDepTypes": ["test"],
      "matchUpdateTypes": ["patch"],
      "automerge": true
    }
  ],
  "vulnerabilityAlerts": {
    "labels": ["security"],
    "automerge": true   // immediate: security patch + green CI = merged
  }
}

The Automation Flywheel

Bot detects new version
  → opens PR with changelog and diff
    → CI: build + unit tests + integration tests + image scan
      → all green + low-risk update
        → auto-merged in ~30 minutes
          → you never manually touched it ✅
💡 Patch updates for well-tested libraries should be noise-free.
Your job: make your test suite trustworthy enough to enable auto-merge.
☕ Demo Gitea + Jenkins + Renovate, fully wired in Docker. Renovate opens real PRs; Jenkins builds them; result posted back to Gitea.
bash Renovate/scripts/demo.sh

🧪 Automation Without Tests is Just Automated Risk

Dependency updates that break behaviour don't cause compilation errors. Without tests, they silently corrupt your application.

  • Serialisation format changed in a patch version
  • Date/time handling differs across library versions
  • A transitive dependency removal removes a capability you relied on
☕ Example jackson-databind patch: field ordering changed, null-handling tightened, date format default shifted — all silently breaking JSON contracts.

The Minimal Safety Net

You don't need 90% line coverage. You need coverage of your integration boundaries — the surfaces that actually break when a dependency changes.

Test typeWhat it catches
Unit testsBusiness logic regressions
Integration tests (real DB/broker)Query behaviour, serialisation, protocol changes
Contract testsAPI-consumer compatibility
Image smoke testRuntime issues invisible at compile time
☕ Concrete targets — every HTTP endpoint, every DB query, every external API call. These are exactly the paths that break when a dep changes behaviour.

Integration Tests with Real Dependencies

Spin up the real database / broker / service in a container for the test — no mocks, no in-memory fakes.

Before: manual wiring
@DynamicPropertySource
static void props(
  DynamicPropertyRegistry r) {
  r.add("spring.datasource.url",
    postgres::getJdbcUrl);
  r.add("spring.datasource.username",
    postgres::getUsername);
}
After: @ServiceConnection
@Container
@ServiceConnection
static PostgreSQLContainer<?> db
  = new PostgreSQLContainer<>(
      "postgres:16-alpine");

// That's it! 🎉

▶ Run Tests 📄 Full Test Source 📊 Latest Maven Result

🔍 Testcontainers — Full Test Source

UserRepositoryIntegrationTest.java — real Postgres container, no mocks

@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    UserRepository userRepository;

    @Test
    void savesAndFindsUser() {
        User saved = userRepository.save(new User("Alice", "alice@example.com"));
        assertThat(saved.getId()).isNotNull();
        User found = userRepository.findById(saved.getId()).orElseThrow();
        assertThat(found.getEmail()).isEqualTo("alice@example.com");
    }

    @Test
    void findsUserByEmail() { /* ... */ }
    @Test
    void deletesUser() { /* ... */ }
    @Test
    void findsAllUsers() { /* ... */ }
}

↗ Full source on GitHub

🔍 Testcontainers — Latest Maven Result

Live, click-through report published to GitHub Pages — Surefire results + captured Maven output, no tab switch needed

↗ Open in new tab

Detect Dead Code in Production

Code coverage in CI tells you what your tests exercise. Coverage from a production agent tells you what real users actually call — revealing removal candidates.

☕ JaCoCo production agent Attach the agent to the running JVM. After a representative period, generate a report.
hello(), users(), user(), create()🟢 Called — keep
HelloController.diagnostics()🔴 Endpoint exists, never hit — remove candidate
UserService.delete()🔴 No endpoint wired at all — dead from day one
bash "JaCoCo/scripts/Retrieve Coverage From Port/run-demo.sh"

▶ Run Demo 📊 View Live Report

☕ Retrieve Coverage — via Port (TCP Agent)

Dump live coverage while the app keeps running — no restart, multiple snapshots

☕ JaCoCo TCP agent The agent opens a TCP server on the JVM. Connect a CLI client to it at any time to pull the in-memory counters — the app never stops.
# 1 — Build the JAR (once per code change)
bash "scripts/Retrieve Coverage From Port/build.sh"

# 2 — Terminal 1: start the app WITH the agent (keep it running)
java "-javaagent:agent.jar=output=tcpserver,port=6300,includes=com.example.*" \
     -jar target/jacoco-demo-0.0.1-SNAPSHOT.jar

# 3 — Terminal 2: exercise the endpoints
bash "scripts/Retrieve Coverage From Port/exercise-endpoints.sh"

# 4 — Terminal 2: dump live coverage from the running JVM
java -jar jacoco-cli.jar dump --address localhost --port 6300 \
     --destfile target/jacoco-live.exec --reset

# 5 — Terminal 2: generate the HTML report
java -jar jacoco-cli.jar report target/jacoco-live.exec \
     --classfiles target/classes --sourcefiles src/main/java \
     --html target/coverage-report

--reset clears the counters after each dump — repeat steps 3–5 to take fresh snapshots without restarting.

☕ Retrieve Coverage — via File (JVM Exit)

Coverage flushes to disk when the JVM exits — no TCP hookup needed

☕ JaCoCo file agent Simplest mode: the agent registers a shutdown hook and writes the exec file the moment the JVM exits. Just stop the app and generate the report.
# 1 — Build the JAR (once per code change)
bash "scripts/Retrieve Coverage From File/build.sh"

# 2 — Terminal 1: start the app WITH the agent
java "-javaagent:agent.jar=destfile=target/jacoco.exec,output=file,append=false,includes=com.example.*" \
     -jar target/jacoco-demo-0.0.1-SNAPSHOT.jar

# 3 — Terminal 2: exercise the endpoints
bash "scripts/Retrieve Coverage From File/exercise-endpoints.sh"

# 4 — Terminal 1: Ctrl+C to stop the app
#     JaCoCo flushes coverage to target/jacoco.exec on JVM exit

# 5 — Terminal 2: generate the HTML report
java -jar jacoco-cli.jar report target/jacoco.exec \
     --classfiles target/classes --sourcefiles src/main/java \
     --html target/coverage-report

No TCP port to manage — best for simple, one-shot runs (e.g. a CI job or a short manual session).

🧪 JaCoCo — Demo Results

run-demo.sh: build → start → exercise → dump → report → stop

exercise-endpoints.sh deliberately calls every endpoint except /admin/diagnostics — simulating real traffic that never touches an unused code path.

── /hello ──────────────────────────────────────────────────
  GET   /hello                       200
  GET   /hello?name=Alice            200
── /users ──────────────────────────────────────────────────
  POST  /users  x3                   200 (Alice, Bob, Carol)
  GET   /users                       200
  GET   /users/1, /users/2           200
  GET   /users/999                   404  ← orElse() branch covered
── /admin/diagnostics — intentionally SKIPPED ───────────────
   Will appear RED / 0% in the coverage report
Result: 3 of 10 methods across HelloController + UserService30% — never execute under this traffic pattern. Report opens at target/coverage-report/index.html.

🔍 JaCoCo — Full Coverage Report

Live, click-through report published to GitHub Pages — drill into packages/classes/lines without leaving the slide

↗ Open in new tab

Automate the Migration Itself

When a framework or language version change requires code edits, automated migration tools can apply the changes — consistently and at scale across your whole codebase.

☕ OpenRewrite Recipe-driven, AST-level refactoring. One command migrates the entire project.
BeforeAfter
Spring Boot 2.7 / JUnit 4 / Java 17Spring Boot 4.1 / JUnit 5 / Java 25
javax.*jakarta.*
String concatenationText blocks
DRY_RUN=true bash OpenRewrite/scripts/run-openrewrite.sh  # preview
bash OpenRewrite/scripts/run-openrewrite.sh               # apply

▶ Run Workflow

⚙️ GitHub Actions — Live Status

Click a badge to view runs · click ▶ to trigger manually

WorkflowStatus
Build Docker Images
Grype Scan
Trivy Scan
OWASP Dependency Check
Compare Scans
Testcontainers
JaCoCo
OpenRewrite
Vulnerable App Build
Renovate Validate

🏁 Key Takeaways

  1. Update incrementally and frequently — big jumps are big risks
  2. Pin versions, not floating tags — reproducibility ≠ security
  3. Scan both source and artifact — they find different vulnerabilities
  4. Automate with a bot — policy belongs in config, not in people's heads
  5. Tests make automation safe — a bot without tests is automated risk
  6. MTTP is your metric — CVE published → production in < 72 hours
  7. Smaller images = smaller attack surface — distroless / scratch = 0 OS CVEs

🛠️ Tools Reference Card

ProblemTool
Runtime version managementSDKMAN! / .sdkmanrc, nvm, pyenv, rbenv…
Build tool versioningMaven Wrapper (mvnw), Gradle Wrapper
Dependency updates discoverymvn versions:display-dependency-updates, ./gradlew dependencyUpdates
Enforce constraints at build timeMaven Enforcer Plugin
SBOM generationCycloneDX Maven Plugin, Syft
Source-level CVE scanningOWASP Dependency Check, Snyk, GitHub Dependabot
Container image CVE scanningTrivy, Docker Scout, Grype
Automated update PRsRenovate Bot, GitHub Dependabot
Automated code migrationOpenRewrite
Integration testingTestcontainers + @ServiceConnection
Production code coverageJaCoCo agent, Azul Code Inventory

📋 Example: Table Slide

Replace with your own data

ToolScansDatabaseOutputFree?
GrypeContainer imagesAnchore FeedTable / JSON / SARIF✅ OSS
TrivyContainer imagesAqua DBTable / JSON / SARIF✅ OSS
OWASP DCBuild manifestsNVDHTML / JSON / SARIF✅ OSS
SnykDeps + imagesSnyk DBHTML / JSON / SARIFFreemium
Docker ScoutContainer imagesMulti-sourceTable / JSON / SARIFFreemium
RenovateDependenciesPackage registriesPull Requests✅ OSS

📊 Example: Bar Chart

📊 Example: Bar Chart with Legend

Stacked severity breakdown — replace with your data

Thank You! 🎉

Repo & slides:
github.com/OWNER/REPO

Questions?