Practical strategies for modern software teams
Your Name
Software Development Conference 2026
All demos, workflows, and reports in this talk live in one public repo
↗ 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.
"Projects don't drift maliciously — just one sprint at a time."
./mvnw dependency:tree | wc -l — prepare to be surprised.
Root cause: a logging library, unused by many teams, hadn't been reviewed in years
The version gap made patching a multi-week project instead of a one-day fix
"If it hurts, do it more often." — Martin Fowler
Your most important security metric
| Update approach | Typical MTTP | Rating |
|---|---|---|
| Manual, quarterly review | 30–90 days | ❌ Dangerous |
| Weekly manual check | 7–14 days | ⚠️ Acceptable |
| Automated bots + auto-merge | 30 min – 2 hours | ✅ Best practice |
Industry benchmark: critical CVE → patched in production within 72 hours.
You cannot hit that with manual quarterly updates.
Modern runtimes and languages ship on a predictable cadence — not when they feel ready, but on a fixed schedule.
Most ecosystems have multiple vendors distributing the same runtime — all standards-compliant, but with different support models and extras.
| Distribution | Vendor | Good for |
|---|---|---|
| Eclipse Temurin | Adoptium | Standard apps, Docker (used in this project) |
| Azul Zulu | Azul | CRaC checkpointing (patched JVM) |
| GraalVM CE | Oracle / community | Native image compilation |
| Amazon Corretto | AWS | AWS-native workloads |
💡 It matters less than you think — all are standards-compliant. Pick one and commit to updating it.
Whether it's a Docker base image, a language runtime, or a build tool — floating version tags silently change under you.
FROM eclipse-temurin:25-jre
# Rebuilt weekly. CI uses a stale
# cached layer from months ago.
FROM eclipse-temurin:25-jre\
@sha256:7ea65de6...
Use automation to update the pin via PR.
.sdkmanrc (java=25.0.1-tem) and mvnw / gradlew wrappers — everyone and CI run the exact same versions.
Every package you install brings its own dependencies. Those bring theirs. The graph grows fast — and most of it is invisible to you.
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.
<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 …Build-tool plugins can compare your locked versions against the latest available in the registry — without modifying anything.
./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!
./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
Don't just warn — fail the build when forbidden or outdated dependencies appear. This prevents regressions from slipping in silently.
<rules>
<requireJavaVersion><version>[21,)</version></requireJavaVersion>
<bannedDependencies>
<excludes>
<exclude>log4j:log4j</exclude> <!-- Log4j 1.x -->
<exclude>commons-logging:commons-logging</exclude>
</excludes>
</bannedDependencies>
</rules>
A Software Bill of Materials is a machine-readable inventory of every component in your artifact — like a nutrition label for software.
./mvnw org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
# Output: target/bom.json — attach to every release artifact
They find different things — you need both
What you declared
What actually ships
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.
Scan dependency manifests against a vulnerability database as part of the build. Fail the build on high-severity findings.
<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 🔴
Scan the built artifact (container image, fat JAR, etc.) for vulnerabilities in both the OS layer and bundled application dependencies.
bash Grype/scripts/scan-image.sh \
eclipse-temurin:25-jre
bash Trivy/scripts/scan-image.sh \
eclipse-temurin:25-jre
Different databases → different findings — use both for complete coverage
Live data — bash "Compare Security Scans/scripts/compare.sh"
Unique CVEs (deduplicated by ID) — Grype vs Trivy, Tot/C/H/M/L/U
| Image | Grype | Trivy | Unique Grype | Unique Trivy |
|---|---|---|---|---|
| eclipse-temurin:25-jre | 96 / 0 / 2 / 87 / 7 / 0 | 60 / 0 / 8 / 47 / 4 / 1 | 48 | 12 |
| debian:12-slim | 79 / 6 / 13 / 24 / 5 / 31 | 84 / 5 / 8 / 30 / 38 / 3 | - | 5 |
| gcr.io/distroless/base-debian12 | 15 / 1 / 2 / 3 / 1 / 8 | 15 / 0 / 0 / 6 / 9 / 0 | - | - |
| gcr.io/distroless/static-debian12 | 0 / 0 / 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 / 0 / 0 | - | - |
| hello-conference:jre-temurin | 110 / 2 / 6 / 95 / 7 / 0 | 74 / 2 / 12 / 55 / 4 / 1 | 48 | 12 |
| hello-conference:jlink-distroless-base | 29 / 3 / 6 / 11 / 1 / 8 | 29 / 2 / 4 / 14 / 9 / 0 | - | - |
| hello-conference:native-scratch | 0 / 0 / 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 / 0 / 0 | - | - |
Live, click-through report published to GitHub Pages — scroll, expand the full breakdown, no tab switch needed
Don't wait for a scheduled scan — get notified as soon as a CVE that affects your dependencies is published.
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.
FROM mybase:v2 six months agoThe 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.
| Strategy | Base | CVEs (Grype) | Size |
|---|---|---|---|
| Full JRE | eclipse-temurin:25-jre | 96 | 477 MB |
| jlink minimal | distroless/base-debian12 | 15 | 33 MB |
| GraalVM native | debian:12-slim | 79 | 116 MB |
| GraalVM native minimal | distroless/static-debian12 | 0 | 6 MB |
| GraalVM native scratch | scratch | 0 | 0 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.
hello-conference app — same code, 9 base 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
As a project grows, the number of dependencies that need attention grows faster than the team's capacity to review them.
| Feature | Dependabot | Renovate |
|---|---|---|
| Maven / Gradle | ✅ | ✅ |
| Docker base images | ✅ | ✅ |
| CI workflow versions | ✅ | ✅ |
| Maven Wrapper version | ❌ | ✅ |
| Grouping updates | Limited | Full control |
| Self-hosted | ❌ | ✅ |
| Config in repo | ❌ | ✅ renovate.json |
| Stability days / merge confidence | ❌ | ✅ |
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
}
}
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 ✅
bash Renovate/scripts/demo.sh
Dependency updates that break behaviour don't cause compilation errors. Without tests, they silently corrupt your application.
jackson-databind patch: field ordering changed, null-handling tightened, date format default shifted — all silently breaking JSON contracts.
You don't need 90% line coverage. You need coverage of your integration boundaries — the surfaces that actually break when a dependency changes.
| Test type | What it catches |
|---|---|
| Unit tests | Business logic regressions |
| Integration tests (real DB/broker) | Query behaviour, serialisation, protocol changes |
| Contract tests | API-consumer compatibility |
| Image smoke test | Runtime issues invisible at compile time |
Spin up the real database / broker / service in a container for the test — no mocks, no in-memory fakes.
@DynamicPropertySource
static void props(
DynamicPropertyRegistry r) {
r.add("spring.datasource.url",
postgres::getJdbcUrl);
r.add("spring.datasource.username",
postgres::getUsername);
}
@ServiceConnection
@Container
@ServiceConnection
static PostgreSQLContainer<?> db
= new PostgreSQLContainer<>(
"postgres:16-alpine");
// That's it! 🎉
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() { /* ... */ }
}
Live, click-through report published to GitHub Pages — Surefire results + captured Maven output, no tab switch needed
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.
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"
Dump live coverage while the app keeps running — no restart, multiple snapshots
# 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.
Coverage flushes to disk when the JVM exits — no TCP hookup needed
# 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).
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
HelloController + UserService — 30% — never execute under this traffic pattern.
Report opens at target/coverage-report/index.html.
Live, click-through report published to GitHub Pages — drill into packages/classes/lines without leaving the slide
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.
| Before | After |
|---|---|
| Spring Boot 2.7 / JUnit 4 / Java 17 | Spring Boot 4.1 / JUnit 5 / Java 25 |
javax.* | jakarta.* |
| String concatenation | Text blocks |
DRY_RUN=true bash OpenRewrite/scripts/run-openrewrite.sh # preview
bash OpenRewrite/scripts/run-openrewrite.sh # apply
Click a badge to view runs · click ▶ to trigger manually
| Problem | Tool |
|---|---|
| Runtime version management | SDKMAN! / .sdkmanrc, nvm, pyenv, rbenv… |
| Build tool versioning | Maven Wrapper (mvnw), Gradle Wrapper |
| Dependency updates discovery | mvn versions:display-dependency-updates, ./gradlew dependencyUpdates |
| Enforce constraints at build time | Maven Enforcer Plugin |
| SBOM generation | CycloneDX Maven Plugin, Syft |
| Source-level CVE scanning | OWASP Dependency Check, Snyk, GitHub Dependabot |
| Container image CVE scanning | Trivy, Docker Scout, Grype |
| Automated update PRs | Renovate Bot, GitHub Dependabot |
| Automated code migration | OpenRewrite |
| Integration testing | Testcontainers + @ServiceConnection |
| Production code coverage | JaCoCo agent, Azul Code Inventory |
Replace with your own data
| Tool | Scans | Database | Output | Free? |
|---|---|---|---|---|
| Grype | Container images | Anchore Feed | Table / JSON / SARIF | ✅ OSS |
| Trivy | Container images | Aqua DB | Table / JSON / SARIF | ✅ OSS |
| OWASP DC | Build manifests | NVD | HTML / JSON / SARIF | ✅ OSS |
| Snyk | Deps + images | Snyk DB | HTML / JSON / SARIF | Freemium |
| Docker Scout | Container images | Multi-source | Table / JSON / SARIF | Freemium |
| Renovate | Dependencies | Package registries | Pull Requests | ✅ OSS |
Stacked severity breakdown — replace with your data
Repo & slides:
github.com/OWNER/REPO
Questions?